选择图片分享功能

This commit is contained in:
2025-08-17 22:08:25 +08:00
parent e6afca5ba6
commit d80289fe73
19 changed files with 1402 additions and 110 deletions

View File

@@ -29,9 +29,22 @@
22F829AF2E50B29C00911ECA /* Jetson MediaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Jetson MediaUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 22F829AF2E50B29C00911ECA /* Jetson MediaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Jetson MediaUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
228E1BB92E50BD5C001E78DA /* Exceptions for "Jetson Media" folder in "Jetson Media" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
ui/Untitled.swift,
);
target = 22F829942E50B29A00911ECA /* Jetson Media */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
22F829972E50B29B00911ECA /* Jetson Media */ = { 22F829972E50B29B00911ECA /* Jetson Media */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
228E1BB92E50BD5C001E78DA /* Exceptions for "Jetson Media" folder in "Jetson Media" target */,
);
path = "Jetson Media"; path = "Jetson Media";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -195,6 +208,9 @@
); );
mainGroup = 22F8298C2E50B29A00911ECA; mainGroup = 22F8298C2E50B29A00911ECA;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
22F16B172E518445003AB150 /* XCRemoteSwiftPackageReference "Kingfisher" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 22F829962E50B29B00911ECA /* Products */; productRefGroup = 22F829962E50B29B00911ECA /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -399,6 +415,9 @@
DEVELOPMENT_TEAM = FXLG7YJAG6; DEVELOPMENT_TEAM = FXLG7YJAG6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Jetson-Media-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "";
INFOPLIST_KEY_LSApplicationCategoryType = "";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -428,6 +447,9 @@
DEVELOPMENT_TEAM = FXLG7YJAG6; DEVELOPMENT_TEAM = FXLG7YJAG6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Jetson-Media-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "";
INFOPLIST_KEY_LSApplicationCategoryType = "";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -558,6 +580,17 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
22F16B172E518445003AB150 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://gh.llkk.cc/https://github.com/onevcat/Kingfisher";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 8.5.0;
};
};
/* End XCRemoteSwiftPackageReference section */
}; };
rootObject = 22F8298D2E50B29A00911ECA /* Project object */; rootObject = 22F8298D2E50B29A00911ECA /* Project object */;
} }

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View File

@@ -0,0 +1,15 @@
{
"originHash" : "72ff32661102b7b480d55d87de58435343e87079b41fe3bf1dca3b79e172b54f",
"pins" : [
{
"identity" : "kingfisher",
"kind" : "remoteSourceControl",
"location" : "https://gh.llkk.cc/https://github.com/onevcat/Kingfisher",
"state" : {
"revision" : "2015fda791daa72c8058619545a593bf8c1dd59f",
"version" : "8.5.0"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildLocationStyle</key>
<string>UseAppPreferences</string>
<key>CustomBuildLocationType</key>
<string>RelativeToDerivedData</string>
<key>DerivedDataLocationStyle</key>
<string>Default</string>
<key>ShowSharedSchemesAutomaticallyEnabled</key>
<true/>
</dict>
</plist>

View File

@@ -9,9 +9,12 @@ import SwiftUI
@main @main
struct Jetson_MediaApp: App { struct Jetson_MediaApp: App {
let tabState = TabBarState() //
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() MainView()
.environmentObject(tabState) // 使
} }
} }
} }

View File

@@ -0,0 +1,15 @@
struct Album: Codable {
let album_id: String
let name: String
let authors: [String]
let actors: [String]
let tags: [String]
let image_urls: [String]
let nums: [Int]
enum CodingKeys: String, CodingKey {
case album_id, name, authors, actors, tags
case image_urls = "image_urls"
case nums
}
}

View File

@@ -0,0 +1,8 @@
//
struct AlbumItem: Codable, Identifiable {
let album_id: String
let title: String
var id: String { album_id }
}

View File

@@ -0,0 +1,27 @@
import Foundation
// API
final class Config {
//
static let shared = Config()
// HOST
private let host = "http://jm.rbq.college"
//
private init() {}
// API
func apiURL(path: String) -> String {
// path
let formattedPath = path.starts(with: "/") ? path : "/\(path)"
return "\(host)\(formattedPath)"
}
// API
struct Path {
static let search = "search/" //
static let albumDetail = "album/" //
static let rankings = "rankings/" //
}
}

View File

@@ -0,0 +1,39 @@
import UIKit
///
class ImageCacheManager {
static let shared = ImageCacheManager()
private let cache = NSCache<NSString, UIImage>()
//
private init() {
//
cache.totalCostLimit = 1024 * 1024 * 100 // 100MB
}
///
/// - Parameter url: URL
/// - Returns:
func getImage(for url: String) -> UIImage? {
return cache.object(forKey: url as NSString)
}
///
/// - Parameters:
/// - image:
/// - url: URL
func saveImage(_ image: UIImage, for url: String) {
cache.setObject(image, forKey: url as NSString)
}
/// URL
/// - Parameter url: URL
func removeImage(for url: String) {
cache.removeObject(forKey: url as NSString)
}
///
func clearAllCache() {
cache.removeAllObjects()
}
}

View File

@@ -0,0 +1,62 @@
import Foundation
class ReadingProgressManager {
static let shared = ReadingProgressManager()
private let userDefaults = UserDefaults.standard
private init() {}
///
func saveProgress(albumId: String, imageIndex: Int) {
userDefaults.set(imageIndex, forKey: progressKey(albumId: albumId))
print("已保存进度 - 漫画ID: \(albumId), 图片索引: \(imageIndex)")
}
///
func getProgress(albumId: String) -> Int {
return userDefaults.integer(forKey: progressKey(albumId: albumId))
}
///
func clearProgress(albumId: String) {
userDefaults.removeObject(forKey: progressKey(albumId: albumId))
}
///
private func progressKey(albumId: String) -> String {
return "readingProgress_\(albumId)"
}
}
///
class FavoriteManager {
static let shared = FavoriteManager()
private let userDefaults = UserDefaults.standard
private let favoriteKey = "favoriteAlbumIds"
private init() {}
/// ID
var favoriteAlbumIds: [String] {
userDefaults.array(forKey: favoriteKey) as? [String] ?? []
}
///
/// - Parameter albumId: ID
func toggleFavorite(albumId: String) {
var favorites = favoriteAlbumIds
if favorites.contains(albumId) {
favorites.removeAll { $0 == albumId }
} else {
favorites.append(albumId)
}
userDefaults.set(favorites, forKey: favoriteKey)
}
///
/// - Parameter albumId: ID
/// - Returns:
func isFavorite(albumId: String) -> Bool {
return favoriteAlbumIds.contains(albumId)
}
}

View File

@@ -1,5 +1,7 @@
import SwiftUI import SwiftUI
import UIKit
///
struct AsyncImageView: View { struct AsyncImageView: View {
let url: String let url: String
let nums: [Int] let nums: [Int]
@@ -8,97 +10,251 @@ struct AsyncImageView: View {
@State private var isLoading = false @State private var isLoading = false
@State private var error: Error? @State private var error: Error?
// @State private var isSelecting = false //
@State private var pressStartTime: Date?
@State private var longPressActive = false
@State private var showShareSheet = false //
private static var currentlyLoadingIndices: Set<Int> = [] private static var currentlyLoadingIndices: Set<Int> = []
private static let maxConcurrentLoads = 4 private static let maxConcurrentLoads = 4
private static let longPressThreshold: TimeInterval = 0.1 //
var body: some View { var body: some View {
Group { Group {
if let image = image { if let image = image {
Image(uiImage: image) ZStack(alignment: .center) {
.resizable() //
.aspectRatio(contentMode: .fit) Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
//
if isSelecting {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.colorMultiply(.white)
.opacity(0.7)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(Color.blue, lineWidth: 3) //
)
.blendMode(.lighten)
//
VStack {
Spacer()
Button(action: {
showShareSheet = true
}) {
Image(systemName: "square.and.arrow.up")
.foregroundColor(.white)
.padding()
.background(Color.blue)
.clipShape(Circle())
.shadow(radius: 4)
}
.padding()
}
}
}
//
.gesture(
MagnificationGesture(minimumScaleDelta: 0)
.onChanged { _ in
handleTwoFingerGesture()
}
.onEnded { _ in
//
let shouldKeepSelection = pressStartTime.map {
Date().timeIntervalSince($0) >= Self.longPressThreshold
} ?? false
withAnimation(.easeOut) {
isSelecting = shouldKeepSelection
}
if !shouldKeepSelection {
pressStartTime = nil
longPressActive = false
}
}
)
//
.sheet(isPresented: $showShareSheet) {
ShareSheet(activityItems: [image]) { completed in
if completed {
withAnimation(.easeOut) {
isSelecting = false
}
pressStartTime = nil
longPressActive = false
}
}
}
} else if isLoading { } else if isLoading {
ProgressView() ProgressView()
.frame(height: 200)
} else if error != nil { } else if error != nil {
VStack { VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.foregroundColor(.red) .foregroundColor(.red)
Text(error?.localizedDescription ?? "未知错误") Text(error?.localizedDescription ?? "未知错误")
.font(.caption) .font(.caption)
.foregroundColor(.gray)
} }
.frame(height: 200)
} else { } else {
Color.gray.opacity(0.3) Color.gray.opacity(0.3)
.frame(height: 200)
} }
} }
.onAppear { .onAppear {
loadImage() loadImage()
} }
.onDisappear {
Self.currentlyLoadingIndices.remove(index)
}
} }
//
private func handleTwoFingerGesture() {
if pressStartTime == nil {
pressStartTime = Date()
} else {
//
let pressDuration = Date().timeIntervalSince(pressStartTime!)
if pressDuration >= Self.longPressThreshold && !longPressActive {
longPressActive = true
//
withAnimation(.easeIn) {
isSelecting = true
}
}
}
}
//
private func loadImage() { private func loadImage() {
// let cacheKey = extractCacheKey(from: url)
// 1. 使
if let cachedImage = ImageCacheManager.shared.getImage(for: cacheKey) {
image = cachedImage
print("从缓存加载 - 键: \(cacheKey)")
return
}
// 2.
guard !Self.currentlyLoadingIndices.contains(index), guard !Self.currentlyLoadingIndices.contains(index),
Self.currentlyLoadingIndices.count < Self.maxConcurrentLoads else { Self.currentlyLoadingIndices.count < Self.maxConcurrentLoads else {
return return
} }
// // 3.
Self.currentlyLoadingIndices.insert(index) Self.currentlyLoadingIndices.insert(index)
isLoading = true isLoading = true
error = nil error = nil
// nums index // 4.
guard let decodeNum = nums[safe: index] else { guard let decodeNum = nums[safe: index] else {
print("错误nums 中未找到索引 \(index) 对应的值,可能 API 数据不一致") error = NSError(domain: "数据错误", code: 1, userInfo: [NSLocalizedDescriptionKey: "图片参数不匹配"])
Self.currentlyLoadingIndices.remove(index)
return
}
print("调试信息:图片 URL: \(url), 解密参数 num: \(decodeNum), 索引: \(index)")
guard let imageUrl = URL(string: url) else {
self.error = NSError(domain: "无效的URL", code: 1, userInfo: nil)
isLoading = false isLoading = false
Self.currentlyLoadingIndices.remove(index) Self.currentlyLoadingIndices.remove(index)
return return
} }
// print("调试信息:加载图片 - URL: \(url), 缓存键: \(cacheKey), 解密参数: \(decodeNum), 索引: \(index)")
// 5. URL
guard let imageUrl = URL(string: url) else {
error = NSError(domain: "URL错误", code: 2, userInfo: [NSLocalizedDescriptionKey: "无效的图片URL"])
isLoading = false
Self.currentlyLoadingIndices.remove(index)
return
}
// 6.
URLSession.shared.dataTask(with: imageUrl) { data, response, error in URLSession.shared.dataTask(with: imageUrl) { data, response, error in
DispatchQueue.main.async { DispatchQueue.main.async {
// //
Self.currentlyLoadingIndices.remove(index) Self.currentlyLoadingIndices.remove(index)
self.isLoading = false self.isLoading = false
//
if let error = error { if let error = error {
self.error = error self.error = error
print("图片加载失败错误: \(error.localizedDescription)") print("图片加载失败(网络错误: \(error.localizedDescription)")
return return
} }
//
guard let httpResponse = response as? HTTPURLResponse, guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode), (200...299).contains(httpResponse.statusCode),
let data = data, !data.isEmpty else { let data = data, !data.isEmpty else {
self.error = NSError(domain: "无效响应", code: 2, userInfo: nil) self.error = NSError(domain: "响应错误", code: 3, userInfo: [NSLocalizedDescriptionKey: "无效的服务器响应"])
print("图片加载失败无效响应或数据为空") print("图片加载失败无效响应")
return return
} }
if let loadedImage = UIImage(data: data) { //
print("图片加载成功,开始解密...") guard let loadedImage = UIImage(data: data) else {
let decodedImage = loadedImage.decodeImage(num: decodeNum) self.error = NSError(domain: "解码错误", code: 4, userInfo: [NSLocalizedDescriptionKey: "无法解析图片数据"])
self.image = decodedImage print("图片加载失败(数据无效)")
print("解密完成,图片已更新") return
} else {
self.error = NSError(domain: "图片数据解码失败", code: 4, userInfo: nil)
print("图片数据解码失败")
} }
// 使
let decodedImage = loadedImage.decodeImage(num: decodeNum)
self.image = decodedImage
ImageCacheManager.shared.saveImage(decodedImage, for: cacheKey)
print("图片加载成功(已缓存) - 键: \(cacheKey)")
} }
}.resume() }.resume()
} }
/// URL
private func extractCacheKey(from urlString: String) -> String {
guard let url = URL(string: urlString) else {
return urlString.hashValue.description
}
let pathComponents = url.pathComponents
if let photosIndex = pathComponents.firstIndex(of: "photos"),
photosIndex + 1 < pathComponents.count,
photosIndex + 2 < pathComponents.count {
let idComponent = pathComponents[photosIndex + 1]
var fileComponent = pathComponents[photosIndex + 2]
if let dotIndex = fileComponent.lastIndex(of: ".") {
fileComponent = String(fileComponent[..<dotIndex])
}
return "\(idComponent)/\(fileComponent)"
}
return url.path.hashValue.description
}
} }
//
struct ShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
let completion: (Bool) -> Void
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
controller.completionWithItemsHandler = { (activityType, completed, returnedItems, error) in
completion(completed)
}
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
// 访
extension Array { extension Array {
subscript(safe index: Int) -> Element? { subscript(safe index: Int) -> Element? {
return indices.contains(index) ? self[index] : nil return indices.contains(index) ? self[index] : nil

View File

@@ -0,0 +1,124 @@
import SwiftUI
import UIKit
// 1. -
class TabBarState: ObservableObject {
@Published var isTabBarHidden = false //
}
// 便
extension TabBarState {
func hideTabBar() { isTabBarHidden = true }
func showTabBar() { isTabBarHidden = false }
func toggleTabBar() { isTabBarHidden.toggle() }
}
// 2. TabBar
class CustomTabBarController: UITabBarController {
var shouldHideTabBar: Bool = false {
didSet {
updateTabBarVisibility()
}
}
override func viewDidLoad() {
super.viewDidLoad()
//
edgesForExtendedLayout = .all // /
extendedLayoutIncludesOpaqueBars = true //
automaticallyAdjustsScrollViewInsets = false // Insets
}
private func updateTabBarVisibility() {
UIView.animate(withDuration: 0.3) {
self.tabBar.isHidden = self.shouldHideTabBar
//
self.viewControllers?.forEach { vc in
vc.additionalSafeAreaInsets.bottom = self.shouldHideTabBar ? 0 : 0
//
vc.view.setNeedsLayout()
}
}
}
}
// 3. SwiftUIUIKit
struct TabBarContainerView: UIViewControllerRepresentable {
@Binding var isTabBarHidden: Bool
init(isTabBarHidden: Binding<Bool>) {
self._isTabBarHidden = isTabBarHidden
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> CustomTabBarController {
let tabBarController = CustomTabBarController()
//
let searchView = SearchView()
.edgesIgnoringSafeArea(.all) //
let searchVC = UIHostingController(rootView: searchView)
searchVC.tabBarItem = UITabBarItem(
title: "搜索",
image: UIImage(systemName: "magnifyingglass"),
tag: 0
)
searchVC.edgesForExtendedLayout = .all
//
let rankingsView = RankingsView()
.edgesIgnoringSafeArea(.all)
let rankingsVC = UIHostingController(rootView: rankingsView)
rankingsVC.tabBarItem = UITabBarItem(
title: "排行榜",
image: UIImage(systemName: "chart.bar"),
tag: 1
)
rankingsVC.edgesForExtendedLayout = .all
//
let profileView = ProfileView()
.edgesIgnoringSafeArea(.all)
let profileVC = UIHostingController(rootView: profileView)
profileVC.tabBarItem = UITabBarItem(
title: "我的",
image: UIImage(systemName: "person"),
tag: 2
)
profileVC.edgesForExtendedLayout = .all
tabBarController.viewControllers = [searchVC, rankingsVC, profileVC]
return tabBarController
}
func updateUIViewController(_ uiViewController: CustomTabBarController, context: Context) {
uiViewController.shouldHideTabBar = isTabBarHidden
}
class Coordinator: NSObject {
var parent: TabBarContainerView
init(_ parent: TabBarContainerView) {
self.parent = parent
}
}
}
// 4.
struct MainView: View {
@EnvironmentObject var tabState: TabBarState
var body: some View {
TabBarContainerView(isTabBarHidden: $tabState.isTabBarHidden)
.navigationTitle("漫画列表")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.edgesIgnoringSafeArea(.all) //
}
}

View File

@@ -1,52 +1,190 @@
import SwiftUI import SwiftUI
import Combine import Combine
import Foundation
import UIKit
struct PhotoView: View { struct PhotoView: View {
let albumId: String let albumId: String
@StateObject private var viewModel: PhotoViewModel @StateObject private var viewModel: PhotoViewModel
@State private var currentImageIndex: Int = 0 //
@State private var progress: Double = 0 // 0-1
@State private var totalImages: Int = 0 //
@State private var isFullscreen = false //
@Environment(\.presentationMode) var presentationMode //
@EnvironmentObject var tabState: TabBarState //
//
private let saveTimer = Timer.publish(every: 2, on: .main, in: .common)
private var timerCancellable: AnyCancellable? //
init(albumId: String) { init(albumId: String) {
self.albumId = albumId self.albumId = albumId
self._viewModel = StateObject(wrappedValue: PhotoViewModel(albumId: albumId)) self._viewModel = StateObject(wrappedValue: PhotoViewModel(albumId: albumId))
self.timerCancellable = saveTimer.autoconnect().sink(receiveValue: { _ in })
} }
var body: some View { var body: some View {
ZStack { ZStack(alignment: .bottom) {
//
Color(isFullscreen ? .black : .systemBackground)
.edgesIgnoringSafeArea(.all)
if viewModel.isLoading { if viewModel.isLoading {
ProgressView() ProgressView("加载中...")
.foregroundColor(isFullscreen ? .white : .black)
} else if let error = viewModel.error {
VStack(spacing: 16) {
Text("加载失败")
.font(.headline)
.foregroundColor(isFullscreen ? .white : .black)
Text(error.localizedDescription)
.font(.subheadline)
.foregroundColor(isFullscreen ? .gray : .secondary)
Button("重试") { viewModel.loadAlbumData() }
.foregroundColor(.blue)
.padding()
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
}
} else if let album = viewModel.album { } else if let album = viewModel.album {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 8) { ScrollViewReader { proxy in
Text(album.name) //
.font(.headline) if !isFullscreen {
VStack(alignment: .leading, spacing: 8) {
Text(album.name)
.font(.headline)
Text("作者: \(album.authors.joined(separator: ", "))")
.font(.subheadline)
.foregroundColor(isFullscreen ? .gray : .secondary)
Text("标签: \(album.tags.joined(separator: ", "))")
.font(.caption)
.foregroundColor(isFullscreen ? .gray : .secondary)
Text("作者: \(album.authors.joined(separator: ", "))") //
.font(.subheadline) Button(action: toggleFavorite) {
Image(systemName: viewModel.isFavorite ? "heart.fill" : "heart")
.foregroundColor(viewModel.isFavorite ? .red : .gray)
Text(viewModel.isFavorite ? "取消收藏" : "收藏")
.foregroundColor(isFullscreen ? .white : .black)
}
.padding(.vertical, 8)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(isFullscreen ? Color.black : Color(.systemBackground))
.id("header")
}
Text("标签: \(album.tags.joined(separator: ", "))") //
.font(.caption) LazyVStack(spacing: 0) {
.foregroundColor(.gray) ForEach(Array(album.image_urls.enumerated()), id: \.offset) { index, url in
} AsyncImageView(
.padding() url: url,
.frame(maxWidth: .infinity, alignment: .leading) nums: album.nums,
.background(Color(.systemBackground)) index: index
)
LazyVStack(spacing: 0) { // .frame(maxWidth: .infinity)
ForEach(Array(album.image_urls.enumerated()), id: \.offset) { index, url in .id(index)
AsyncImageView( //
url: url, .onAppear {
nums: album.nums, //
index: index if index > currentImageIndex || abs(index - currentImageIndex) > 3 {
) currentImageIndex = index
.frame(maxWidth: .infinity) // updateProgressRatio()
.aspectRatio(contentMode: .fill) // }
.clipped() // }
}
}
.onAppear {
totalImages = album.image_urls.count
//
let savedIndex = ReadingProgressManager.shared.getProgress(albumId: albumId)
currentImageIndex = clamp(value: savedIndex, min: 0, max: totalImages - 1)
updateProgressRatio()
// PhotoViewonAppear
UserDefaults.standard.addHistory(albumId: albumId)
//
print("上次阅读位置: \(savedIndex)")
if totalImages > 0, currentImageIndex > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation {
proxy.scrollTo(currentImageIndex, anchor: .top)
}
}
}
}
//
.onChange(of: progress) { newValue in
let targetIndex = Int(newValue * Double(max(0, totalImages - 1)))
let clampedIndex = clamp(value: targetIndex, min: 0, max: totalImages - 1)
if clampedIndex != currentImageIndex {
currentImageIndex = clampedIndex
withAnimation {
proxy.scrollTo(currentImageIndex, anchor: .top)
}
}
} }
} }
} }
.edgesIgnoringSafeArea(.all) // .padding(.top, isFullscreen ? 0 : 60)
} else if let error = viewModel.error { .edgesIgnoringSafeArea(isFullscreen ? .all : .vertical)
Text("加载失败: \(error.localizedDescription)") .onTapGesture {
//
withAnimation {
tabState.toggleTabBar()
isFullscreen.toggle()
adjustSystemUI()
}
}
//
.onReceive(saveTimer) { _ in
guard totalImages > 0 else { return }
ReadingProgressManager.shared.saveProgress(albumId: albumId, imageIndex: currentImageIndex)
}
.onDisappear {
ReadingProgressManager.shared.saveProgress(albumId: albumId, imageIndex: currentImageIndex)
timerCancellable?.cancel() //
// UI
UIApplication.shared.isStatusBarHidden = false
}
//
if isFullscreen {
//
VStack {
HStack {
//
Button(action: { presentationMode.wrappedValue.dismiss() }) {
Image(systemName: "chevron.left")
.foregroundColor(.white)
.padding()
.background(Color.black.opacity(0.5))
.cornerRadius(8)
}
Spacer()
}
.padding()
Spacer()
}
}
if totalImages > 0 && !isFullscreen {
CustomSlider(
value: $progress,
trackHeight: 20, // 20px
tintColor: .blue
)
.background(Color(.systemBackground).opacity(0.9))
.cornerRadius(8) //
.shadow(radius: 5)
.padding(.horizontal, 30) //
.padding(.bottom, tabState.isTabBarHidden ? 30 : 60) //
.padding(.top, 10) //
}
} }
} }
.alert(isPresented: $viewModel.showAlert) { .alert(isPresented: $viewModel.showAlert) {
@@ -56,16 +194,62 @@ struct PhotoView: View {
dismissButton: .default(Text("确定")) dismissButton: .default(Text("确定"))
) )
} }
.navigationBarHidden(isFullscreen)
.statusBarHidden(isFullscreen)
.onAppear {
// UI
adjustSystemUI()
}
.onDisappear {
//
tabState.showTabBar()
}
}
// UI
private func adjustSystemUI() {
UIApplication.shared.isStatusBarHidden = isFullscreen
//
UIApplication.shared.windows.first?.rootViewController?.setNeedsStatusBarAppearanceUpdate()
}
//
private func updateProgressRatio() {
guard totalImages > 0 else {
progress = 0
return
}
let safeTotal = max(1, totalImages - 1) // 0
progress = Double(currentImageIndex) / Double(safeTotal)
}
//
private func toggleFavorite() {
FavoriteManager.shared.toggleFavorite(albumId: albumId)
viewModel.isFavorite = FavoriteManager.shared.isFavorite(albumId: albumId)
}
//
private func clamp(value: Int, min: Int, max: Int) -> Int {
return Swift.max(min, Swift.min(value, max))
} }
} }
// PhotoView, PhotoViewModel, Album
//
struct ScrollOffsetKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
class PhotoViewModel: ObservableObject { class PhotoViewModel: ObservableObject {
let albumId: String let albumId: String
@Published var album: Album? @Published var album: Album?
@Published var isLoading = false @Published var isLoading = false
@Published var error: Error? @Published var error: Error?
@Published var scrollPosition: Double = 0 @Published var isFavorite: Bool = false //
@Published var showAlert = false @Published var showAlert = false
@Published var alertTitle = "" @Published var alertTitle = ""
@Published var alertMessage = "" @Published var alertMessage = ""
@@ -74,17 +258,24 @@ class PhotoViewModel: ObservableObject {
init(albumId: String) { init(albumId: String) {
self.albumId = albumId self.albumId = albumId
checkIfFavorite()
loadAlbumData() loadAlbumData()
} }
//
private func checkIfFavorite() {
isFavorite = FavoriteManager.shared.isFavorite(albumId: albumId)
}
func loadAlbumData() { func loadAlbumData() {
isLoading = true isLoading = true
error = nil error = nil
album = nil //
let urlString = "https://jms.godserver.cn/album/\(albumId)/" let urlString = Config.shared.apiURL(path: "album/\(albumId)/")
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
error = NSError(domain: "无效的URL", code: 0, userInfo: nil) let error = NSError(domain: "PhotoViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: "无效的URL"])
isLoading = false handleError(error: error)
return return
} }
@@ -97,34 +288,29 @@ class PhotoViewModel: ObservableObject {
.sink(receiveCompletion: { [weak self] completion in .sink(receiveCompletion: { [weak self] completion in
self?.isLoading = false self?.isLoading = false
if case .failure(let error) = completion { if case .failure(let error) = completion {
self?.error = error self?.handleError(error: error)
print("相册加载失败: \(error.localizedDescription)")
} }
}, receiveValue: { [weak self] album in }, receiveValue: { [weak self] album in
self?.isLoading = false
self?.album = album self?.album = album
print("成功加载相册: \(album.name), 包含 \(album.image_urls.count) 张图片") print("成功加载相册: \(album.name), 包含 \(album.image_urls.count) 张图片")
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
//
private func handleError(error: Error) {
self.error = error
self.alertTitle = "加载失败"
self.alertMessage = error.localizedDescription
self.showAlert = true
self.isLoading = false
print("相册加载失败: \(error.localizedDescription)")
}
func showAlert(title: String, message: String) { func showAlert(title: String, message: String) {
alertTitle = title alertTitle = title
alertMessage = message alertMessage = message
showAlert = true showAlert = true
} }
} }
struct Album: Codable {
let album_id: String
let name: String
let authors: [String]
let actors: [String]
let tags: [String]
let image_urls: [String]
let nums: [Int]
enum CodingKeys: String, CodingKey {
case album_id, name, authors, actors, tags
case image_urls = "image_urls"
case nums
}
}

View File

@@ -0,0 +1,291 @@
import SwiftUI
import Foundation
// UserDefaults
extension UserDefaults {
// [album_id: 访]
private var historyRecordsKey: String { "history_records" }
var historyRecords: [String: TimeInterval] {
get {
(object(forKey: historyRecordsKey) as? [String: TimeInterval]) ?? [:]
}
set {
set(newValue, forKey: historyRecordsKey)
}
}
//
func addHistory(albumId: String) {
var records = historyRecords
records[albumId] = Date().timeIntervalSince1970 //
historyRecords = records
}
//
func removeHistory(albumId: String) {
var records = historyRecords
records.removeValue(forKey: albumId)
historyRecords = records
}
//
func clearAllHistory() {
historyRecords = [:]
}
}
struct ProfileView: View {
//
let username = "Jetson User"
//
@State private var favoriteAlbums: [AlbumItem] = []
@State private var isLoadingFavorites = false
//
@State private var historyAlbums: [AlbumItem] = []
@State private var isLoadingHistory = false
@State private var showHistoryEmpty = false
//
@State private var showError = false
@State private var errorMessage = ""
var body: some View {
NavigationStack {
List {
//
Section {
HStack(alignment: .center, spacing: 12) {
//
Image(systemName: "person.circle.fill")
.resizable()
.frame(width: 80, height: 80)
.foregroundColor(.blue)
//
VStack(spacing: 4) {
Text(username)
.font(.headline)
}
}
.padding(.vertical, 20)
}
//
Section(header: Text("我的收藏")
.font(.headline)) {
if isLoadingFavorites {
HStack {
Spacer()
ProgressView("加载收藏中...")
Spacer()
}
.padding(.vertical, 20)
} else if favoriteAlbums.isEmpty {
Text("暂无收藏,快去收藏喜欢的漫画吧~")
.foregroundColor(.gray)
.padding(.vertical, 20)
} else {
ForEach(favoriteAlbums) { item in
NavigationLink(destination: PhotoView(albumId: item.album_id)) {
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.subheadline)
.lineLimit(2)
Text("ID: \(item.album_id)")
.font(.caption)
.foregroundColor(.gray)
}
}
}
.onDelete(perform: removeFromFavorites)
}
}
//
Section(header: HStack {
Text("浏览历史")
.font(.headline)
Spacer()
Button("清除全部") {
clearAllHistory()
}
.foregroundColor(.red)
.font(.subheadline)
}) {
if isLoadingHistory {
HStack {
Spacer()
ProgressView("加载历史中...")
Spacer()
}
.padding(.vertical, 20)
} else if historyAlbums.isEmpty {
Text("暂无浏览记录")
.foregroundColor(.gray)
.padding(.vertical, 20)
} else {
ForEach(historyAlbums) { item in
NavigationLink(destination: PhotoView(albumId: item.album_id)) {
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.subheadline)
.lineLimit(2)
Text("ID: \(item.album_id)")
.font(.caption)
.foregroundColor(.gray)
}
}
}
.onDelete(perform: removeFromHistory)
}
}
//
Section(header: Text("设置")) {
Button(action: {}) {
HStack {
Image(systemName: "gear")
.foregroundColor(.gray)
Text("设置")
}
}
}
}
.navigationTitle("我的")
.toolbar {
EditButton()
}
.alert(isPresented: $showError) {
Alert(title: Text("加载失败"), message: Text(errorMessage), dismissButton: .default(Text("确定")))
}
.onAppear {
loadFavoriteAlbums()
loadHistoryAlbums() //
}
}
}
// MARK: -
private func loadFavoriteAlbums() {
let favoriteIds = UserDefaults.standard.favoriteAlbumIds
guard !favoriteIds.isEmpty else {
favoriteAlbums = []
return
}
isLoadingFavorites = true
favoriteAlbums = []
let group = DispatchGroup()
for id in favoriteIds {
group.enter()
fetchAlbumInfo(albumId: id) { albumItem in
if let item = albumItem {
favoriteAlbums.append(item)
}
group.leave()
}
}
group.notify(queue: .main) {
favoriteAlbums.sort { $0.album_id > $1.album_id }
isLoadingFavorites = false
}
}
private func removeFromFavorites(at offsets: IndexSet) {
guard let index = offsets.first else { return }
let removedItem = favoriteAlbums[index]
favoriteAlbums.remove(at: index)
var favorites = UserDefaults.standard.favoriteAlbumIds
favorites.removeAll { $0 == removedItem.album_id }
UserDefaults.standard.favoriteAlbumIds = favorites
}
// MARK: -
private func loadHistoryAlbums() {
let historyRecords = UserDefaults.standard.historyRecords
//
let sortedIds = historyRecords.keys.sorted {
historyRecords[$0] ?? 0 > historyRecords[$1] ?? 0
}
guard !sortedIds.isEmpty else {
historyAlbums = []
return
}
isLoadingHistory = true
historyAlbums = []
let group = DispatchGroup()
for id in sortedIds {
group.enter()
fetchAlbumInfo(albumId: id) { albumItem in
if let item = albumItem {
historyAlbums.append(item)
}
group.leave()
}
}
group.notify(queue: .main) {
isLoadingHistory = false
}
}
private func removeFromHistory(at offsets: IndexSet) {
guard let index = offsets.first else { return }
let removedItem = historyAlbums[index]
historyAlbums.remove(at: index)
UserDefaults.standard.removeHistory(albumId: removedItem.album_id)
}
private func clearAllHistory() {
UserDefaults.standard.clearAllHistory()
historyAlbums = []
}
// MARK: -
private func fetchAlbumInfo(albumId: String, completion: @escaping (AlbumItem?) -> Void) {
let urlString = "http://jm.rbq.college/album/\(albumId)/"
guard let url = URL(string: urlString) else {
completion(nil)
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
DispatchQueue.main.async {
showError(message: "加载失败: \(error.localizedDescription)")
}
completion(nil)
return
}
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200,
let data = data else {
completion(nil)
return
}
do {
let album = try JSONDecoder().decode(Album.self, from: data)
let albumItem = AlbumItem(album_id: album.album_id, title: album.name)
completion(albumItem)
} catch {
print("解析数据失败: \(error)")
completion(nil)
}
}.resume()
}
private func showError(message: String) {
errorMessage = message
showError = true
}
}

View File

@@ -0,0 +1,116 @@
import SwiftUI
struct RankingsView: View {
@StateObject private var viewModel = RankingsViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("加载排行榜...")
} else if let error = viewModel.error {
VStack {
Text("加载失败: \(error.localizedDescription)")
Button("重试") {
viewModel.loadRankings()
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
} else {
List {
ForEach(Array(viewModel.rankings.enumerated()), id: \.element.album_id) { index, item in
HStack(alignment: .center, spacing: 12) {
//
Text("\(index + 1)")
.font(.headline)
.frame(width: 24, height: 24)
.background(index < 3 ? Color.yellow : Color.gray)
.foregroundColor(index < 3 ? Color.black : Color.white)
.cornerRadius(4)
.padding(.leading, 4)
//
NavigationLink(destination: PhotoView(albumId: item.album_id)) {
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.subheadline)
.lineLimit(1)
Text("ID: \(item.album_id)")
.font(.caption)
.foregroundColor(.gray)
}
}
Spacer()
//
Image(systemName: "flame.fill")
.foregroundColor(.orange)
}
}
}
.refreshable {
viewModel.loadRankings()
}
}
}
.navigationTitle("周热度排行")
}
}
}
//
class RankingsViewModel: ObservableObject {
@Published var rankings: [AlbumItem] = []
@Published var isLoading = false
@Published var error: Error?
init() {
loadRankings()
}
func loadRankings() {
isLoading = true
error = nil
let urlString = Config.shared.apiURL(path: "rankings/week?page=1")
guard let url = URL(string: urlString) else {
error = NSError(domain: "RankingsError", code: 0, userInfo: [NSLocalizedDescriptionKey: "无效的URL"])
isLoading = false
return
}
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
DispatchQueue.main.async {
self?.isLoading = false
if let error = error {
self?.error = error
return
}
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
self?.error = NSError(domain: "RankingsError", code: 1, userInfo: [NSLocalizedDescriptionKey: "服务器返回错误"])
return
}
guard let data = data else {
self?.error = NSError(domain: "RankingsError", code: 2, userInfo: [NSLocalizedDescriptionKey: "没有接收到数据"])
return
}
do {
let decoder = JSONDecoder()
self?.rankings = try decoder.decode([AlbumItem].self, from: data)
} catch {
self?.error = NSError(domain: "RankingsError", code: 3, userInfo: [NSLocalizedDescriptionKey: "解析数据失败: \(error.localizedDescription)"])
print("排行榜解码错误: \(error)")
}
}
}.resume()
}
}

View File

@@ -1,44 +1,133 @@
import SwiftUI import SwiftUI
import Foundation
//
class SearchHistoryManager {
static let shared = SearchHistoryManager()
private let userDefaultsKey = "search_history"
private let maxHistoryCount = 50
var history: [String] {
UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? []
}
func addHistory(_ query: String) {
var newHistory = history
if let index = newHistory.firstIndex(of: query) {
newHistory.remove(at: index)
}
newHistory.insert(query, at: 0)
if newHistory.count > maxHistoryCount {
newHistory = Array(newHistory.prefix(maxHistoryCount))
}
UserDefaults.standard.set(newHistory, forKey: userDefaultsKey)
}
func removeHistory(_ query: String) {
var newHistory = history
if let index = newHistory.firstIndex(of: query) {
newHistory.remove(at: index)
UserDefaults.standard.set(newHistory, forKey: userDefaultsKey)
}
}
func clearAllHistory() {
UserDefaults.standard.removeObject(forKey: userDefaultsKey)
}
}
//
struct SearchView: View { struct SearchView: View {
@State private var searchQuery: String = "" @State private var searchQuery: String = ""
@State private var searchResults: [AlbumItem] = [] @State private var searchResults: [AlbumItem] = []
@State private var isSearching: Bool = false @State private var isSearching: Bool = false
@State private var showAlert: Bool = false @State private var showAlert: Bool = false
@State private var alertMessage: String = "" @State private var alertMessage: String = ""
@State private var searchHistory: [String] = []
var body: some View { var body: some View {
Form { NavigationStack {
Section(header: Text("搜索")) { Form {
HStack { Section(header: Text("Jetson Media")) {
TextField("输入搜索内容", text: $searchQuery) HStack {
.textFieldStyle(RoundedBorderTextFieldStyle()) TextField("输入搜索内容", text: $searchQuery)
.textFieldStyle(RoundedBorderTextFieldStyle())
.onChange(of: searchQuery) {
if $0.isEmpty {
loadHistory()
}
}
Button(action: performSearch) { Button(action: performSearch) {
if isSearching { if isSearching {
ProgressView() ProgressView()
} else { } else {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
}
} }
.disabled(searchQuery.isEmpty || isSearching)
} }
.disabled(searchQuery.isEmpty || isSearching)
} }
}
if !searchResults.isEmpty { //
Section(header: Text("搜索结果")) { if searchQuery.isEmpty && !searchHistory.isEmpty {
ForEach(searchResults, id: \.album_id) { item in Section(header: HStack {
NavigationLink(destination: PhotoView(albumId: item.album_id)) { Text("搜索历史")
VStack(alignment: .leading) { Spacer()
Text(item.title) Button("清除全部") {
.font(.headline) SearchHistoryManager.shared.clearAllHistory()
Text("ID: \(item.album_id)") loadHistory()
.font(.subheadline) }
.foregroundColor(.gray) .foregroundColor(.red)
.font(.subheadline)
}) {
ForEach(searchHistory, id: \.self) { query in
// HStack
HStack {
//
Text(query)
.foregroundColor(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
.onTapGesture {
//
searchQuery = query
performSearch()
}
//
Button(action: {
SearchHistoryManager.shared.removeHistory(query)
loadHistory()
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
}
.buttonStyle(PlainButtonStyle()) //
} }
} }
} }
} }
//
if !searchResults.isEmpty {
Section(header: Text("搜索结果")) {
ForEach(searchResults, id: \.album_id) { item in
NavigationLink(destination: PhotoView(albumId: item.album_id)) {
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text("ID: \(item.album_id)")
.font(.subheadline)
.foregroundColor(.gray)
}
}
}
}
}
}
.navigationTitle("搜索")
.onAppear {
loadHistory()
} }
} }
.alert(isPresented: $showAlert) { .alert(isPresented: $showAlert) {
@@ -46,15 +135,23 @@ struct SearchView: View {
} }
} }
private func loadHistory() {
searchHistory = SearchHistoryManager.shared.history
}
private func performSearch() { private func performSearch() {
guard !searchQuery.isEmpty else { return } guard !searchQuery.isEmpty else { return }
SearchHistoryManager.shared.addHistory(searchQuery)
loadHistory()
isSearching = true isSearching = true
searchResults = [] searchResults = []
let encodedQuery = searchQuery.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? searchQuery let encodedQuery = searchQuery.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? searchQuery
let urlString = "https://jms.godserver.cn/search/?search_query=\(encodedQuery)&page=1" let urlString = Config.shared.apiURL(
path: "\(Config.Path.search)?search_query=\(encodedQuery)&page=1"
)
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
showAlert(message: "无效的URL") showAlert(message: "无效的URL")
isSearching = false isSearching = false
@@ -91,6 +188,7 @@ struct SearchView: View {
} }
task.resume() task.resume()
} }
private func showAlert(message: String) { private func showAlert(message: String) {
alertMessage = message alertMessage = message
showAlert = true showAlert = true

View File

@@ -0,0 +1,29 @@
import SwiftUI
import Combine
import Foundation
// UserDefaults
extension UserDefaults {
// ID
var favoriteAlbumIds: [String] {
get {
return array(forKey: "favoriteAlbumIds") as? [String] ?? []
}
set {
set(newValue, forKey: "favoriteAlbumIds")
}
}
//
func saveReadingProgress(albumId: String, position: Double) {
set(position, forKey: "readingProgress_\(albumId)")
}
//
func getReadingProgress(albumId: String) -> Double {
return double(forKey: "readingProgress_\(albumId)")
}
private var favoriteAlbumIdsKey: String { "favorite_album_ids" }
}

View File

@@ -0,0 +1,56 @@
import SwiftUI
import UIKit
// 1. UIKit
class ThickSlider: UISlider {
var trackHeight: CGFloat = 40 //
override func trackRect(forBounds bounds: CGRect) -> CGRect {
//
let original = super.trackRect(forBounds: bounds)
return CGRect(x: original.origin.x,
y: (bounds.height - trackHeight) / 2,
width: original.width,
height: trackHeight)
}
}
// 2. SwiftUI
struct CustomSlider: UIViewRepresentable {
@Binding var value: Double
var trackHeight: CGFloat
var tintColor: Color
func makeUIView(context: Context) -> ThickSlider {
let slider = ThickSlider()
slider.trackHeight = trackHeight
slider.minimumValue = 0
slider.maximumValue = 1
slider.value = Float(value)
slider.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged(_:)), for: .valueChanged)
slider.tintColor = UIColor(tintColor)
return slider
}
func updateUIView(_ uiView: ThickSlider, context: Context) {
uiView.value = Float(value)
uiView.trackHeight = trackHeight
uiView.tintColor = UIColor(tintColor)
}
func makeCoordinator() -> Coordinator {
Coordinator(value: $value)
}
class Coordinator: NSObject {
@Binding var value: Double
init(value: Binding<Double>) {
_value = value
}
@objc func valueChanged(_ sender: UISlider) {
value = Double(sender.value)
}
}
}

15
Jetson-Media-Info.plist Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>Privacy - Photo Library Add Usage Description</key>
<string></string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>