首次提交
This commit is contained in:
parent
44bf2c4512
commit
001f392af6
47
.gitignore
vendored
47
.gitignore
vendored
@ -13,6 +13,7 @@ xcuserdata/
|
|||||||
*.ipa
|
*.ipa
|
||||||
*.dSYM.zip
|
*.dSYM.zip
|
||||||
*.dSYM
|
*.dSYM
|
||||||
|
Podfile.lock
|
||||||
|
|
||||||
## Playgrounds
|
## Playgrounds
|
||||||
timeline.xctimeline
|
timeline.xctimeline
|
||||||
@ -32,6 +33,52 @@ playground.xcworkspace
|
|||||||
|
|
||||||
.build/
|
.build/
|
||||||
|
|
||||||
|
# CocoaPods
|
||||||
|
#
|
||||||
|
# We recommend against adding the Pods directory to your .gitignore. However
|
||||||
|
# you should judge for yourself, the pros and cons are mentioned at:
|
||||||
|
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
||||||
|
#
|
||||||
|
Pods/
|
||||||
|
#
|
||||||
|
# Add this line if you want to avoid checking in source code from the Xcode workspace
|
||||||
|
*.xcworkspace
|
||||||
|
|
||||||
|
# Carthage
|
||||||
|
#
|
||||||
|
# Add this line if you want to avoid checking in source code from Carthage dependencies.
|
||||||
|
# Carthage/Checkouts
|
||||||
|
|
||||||
|
Carthage/Build/
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
#
|
||||||
|
# It is recommended to not store the screenshots in the git repo.
|
||||||
|
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
|
||||||
|
# For more information about the recommended setup visit:
|
||||||
|
# https://docs.fastlane.tools/best-practices/source-control/#source-control
|
||||||
|
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots/**/*.png
|
||||||
|
fastlane/test_output
|
||||||
|
|
||||||
|
# ---> Objective-C
|
||||||
|
# Xcode
|
||||||
|
#
|
||||||
|
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
||||||
|
|
||||||
|
## User settings
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
|
## Obj-C/Swift specific
|
||||||
|
*.hmap
|
||||||
|
|
||||||
|
## App packaging
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM
|
||||||
|
|
||||||
# CocoaPods
|
# CocoaPods
|
||||||
#
|
#
|
||||||
# We recommend against adding the Pods directory to your .gitignore. However
|
# We recommend against adding the Pods directory to your .gitignore. However
|
||||||
|
|||||||
43
Podfile
Normal file
43
Podfile
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# Uncomment the next line to define a global platform for your project
|
||||||
|
platform :ios, '15.0'
|
||||||
|
|
||||||
|
target 'XSeri' do
|
||||||
|
# Comment the next line if you don't want to use dynamic frameworks
|
||||||
|
use_frameworks!
|
||||||
|
|
||||||
|
# Pods for XSeri
|
||||||
|
pod 'SnapKit'
|
||||||
|
pod 'Kingfisher'
|
||||||
|
pod 'Moya'
|
||||||
|
pod 'SmartCodable', '5.0.15'
|
||||||
|
pod 'ESTabBarController-swift'
|
||||||
|
pod 'JXSegmentedView'
|
||||||
|
pod 'collection-view-layouts/TagsLayout'
|
||||||
|
pod 'FSPagerView'
|
||||||
|
pod 'YYCategories'
|
||||||
|
pod 'YYText'
|
||||||
|
pod 'JXPlayer'
|
||||||
|
pod 'LYEmptyView'
|
||||||
|
pod 'FDFullscreenPopGesture'
|
||||||
|
pod 'SVProgressHUD'
|
||||||
|
pod 'Toast'
|
||||||
|
pod 'MJRefresh'
|
||||||
|
pod 'HWPanModal'
|
||||||
|
pod 'ZLPhotoBrowser'
|
||||||
|
|
||||||
|
post_install do |installer|
|
||||||
|
installer.pods_project.targets.each do |target|
|
||||||
|
target.build_configurations.each do |config|
|
||||||
|
# 统一设置部署版本
|
||||||
|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
|
||||||
|
|
||||||
|
# 移除可能导致模拟器运行报错的排除架构设置,让 Xcode 自动处理
|
||||||
|
config.build_settings.delete('EXCLUDED_ARCHS[sdk=iphonesimulator*]')
|
||||||
|
config.build_settings.delete('EXCLUDED_ARCHITECTURES')
|
||||||
|
|
||||||
|
# 禁用 Bitcode (现代项目通常不需要)
|
||||||
|
config.build_settings['ENABLE_BITCODE'] = 'NO'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
1247
XSeri.xcodeproj/project.pbxproj
Normal file
1247
XSeri.xcodeproj/project.pbxproj
Normal file
File diff suppressed because it is too large
Load Diff
23
XSeri/AppScene/AppDelegate+Config.swift
Normal file
23
XSeri/AppScene/AppDelegate+Config.swift
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// AppDelegate+Config.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
import UIKit
|
||||||
|
import MJRefresh
|
||||||
|
|
||||||
|
extension AppDelegate {
|
||||||
|
|
||||||
|
func config() {
|
||||||
|
UIView.xs_awake()
|
||||||
|
XSToast.config()
|
||||||
|
|
||||||
|
MJRefreshConfig.default.languageCode = "en"
|
||||||
|
|
||||||
|
let appearance = UINavigationBarAppearance.defaultAppearance()
|
||||||
|
UINavigationBar.appearance().scrollEdgeAppearance = appearance
|
||||||
|
UINavigationBar.appearance().standardAppearance = appearance
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
53
XSeri/AppScene/AppDelegate.swift
Normal file
53
XSeri/AppScene/AppDelegate.swift
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
//
|
||||||
|
// AppDelegate.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
|
|
||||||
|
XSNetworkMonitorManager.manager.startMonitoring()
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(networkStatusDidChangeNotification), name: XSNetworkMonitorManager.networkStatusDidChangeNotification, object: nil)
|
||||||
|
self.config()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await XSLoginManager.manager.updateUserInfo()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: UISceneSession Lifecycle
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||||
|
// Called when a new scene session is being created.
|
||||||
|
// Use this method to select a configuration to create the new scene with.
|
||||||
|
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
|
||||||
|
// Called when the user discards a scene session.
|
||||||
|
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
|
||||||
|
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppDelegate {
|
||||||
|
|
||||||
|
@objc private func networkStatusDidChangeNotification() {
|
||||||
|
guard XSNetworkMonitorManager.manager.isReachable == true else { return }
|
||||||
|
Task {
|
||||||
|
await XSLoginManager.manager.updateUserInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
53
XSeri/AppScene/SceneDelegate.swift
Normal file
53
XSeri/AppScene/SceneDelegate.swift
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
//
|
||||||
|
// SceneDelegate.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
|
var window: UIWindow?
|
||||||
|
|
||||||
|
|
||||||
|
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||||
|
guard let windowScene = (scene as? UIWindowScene) else { return }
|
||||||
|
XSTool.windowScene = windowScene
|
||||||
|
|
||||||
|
self.window = UIWindow(windowScene: windowScene)
|
||||||
|
self.window?.rootViewController = XSTabBarController()
|
||||||
|
self.window?.makeKeyAndVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneDidDisconnect(_ scene: UIScene) {
|
||||||
|
// Called as the scene is being released by the system.
|
||||||
|
// This occurs shortly after the scene enters the background, or when its session is discarded.
|
||||||
|
// Release any resources associated with this scene that can be re-created the next time the scene connects.
|
||||||
|
// The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||||
|
// Called when the scene has moved from an inactive state to an active state.
|
||||||
|
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneWillResignActive(_ scene: UIScene) {
|
||||||
|
// Called when the scene will move from an active state to an inactive state.
|
||||||
|
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneWillEnterForeground(_ scene: UIScene) {
|
||||||
|
// Called as the scene transitions from the background to the foreground.
|
||||||
|
// Use this method to undo the changes made on entering the background.
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneDidEnterBackground(_ scene: UIScene) {
|
||||||
|
// Called as the scene transitions from the foreground to the background.
|
||||||
|
// Use this method to save data, release shared resources, and store enough scene-specific state information
|
||||||
|
// to restore the scene back to its current state.
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
28
XSeri/Base/Constants/XSConfig.swift
Normal file
28
XSeri/Base/Constants/XSConfig.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 全局样式常量配置
|
||||||
|
struct XSConfig {
|
||||||
|
|
||||||
|
struct Color {
|
||||||
|
/// TabBar 背景色 (#0F0F0F)
|
||||||
|
static let tabBarBg = UIColor(red: 0.059, green: 0.059, blue: 0.059, alpha: 1.0)
|
||||||
|
|
||||||
|
/// TabBar 选中颜色 (#FFDAA4)
|
||||||
|
static let tabBarActive = UIColor(red: 1.0, green: 0.855, blue: 0.643, alpha: 1.0)
|
||||||
|
|
||||||
|
/// TabBar 未选中颜色 (白色 40% 透明度)
|
||||||
|
static let tabBarInactive = UIColor.white.withAlphaComponent(0.4)
|
||||||
|
|
||||||
|
/// TabBar 分割线颜色 (白色 4% 透明度)
|
||||||
|
static let tabBarSeparator = UIColor.white.withAlphaComponent(0.04)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Font {
|
||||||
|
/// TabBar 选中字体 (Medium 12pt)
|
||||||
|
static let tabBarActive = UIFont.systemFont(ofSize: 12, weight: .medium)
|
||||||
|
|
||||||
|
/// TabBar 未选中字体 (Regular 11pt)
|
||||||
|
static let tabBarInactive = UIFont.systemFont(ofSize: 11, weight: .regular)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
47
XSeri/Base/Constants/XSDefine.swift
Normal file
47
XSeri/Base/Constants/XSDefine.swift
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// XSDefine.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
///当前系统版本号
|
||||||
|
let kXSSystemVersion: String = UIDevice.current.systemVersion
|
||||||
|
let kXSBundleIdentifier: String = (Bundle.main.infoDictionary!["CFBundleIdentifier"] as? String) ?? "0"
|
||||||
|
|
||||||
|
///app版本号
|
||||||
|
let kXSVersion: String = (Bundle.main.infoDictionary!["CFBundleShortVersionString"] as? String) ?? "0"
|
||||||
|
let kXSBundleVersion: String = (Bundle.main.infoDictionary!["CFBundleVersion"] as? String) ?? "0"
|
||||||
|
|
||||||
|
let kXSName: String = (Bundle.main.infoDictionary!["CFBundleDisplayName"] as? String) ?? ""
|
||||||
|
let kXSBundleName: String = (Bundle.main.infoDictionary!["CFBundleName"] as? String) ?? ""
|
||||||
|
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
func xsLog(_ msg: Any?, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
|
if let msg = msg {
|
||||||
|
print("\n\(Date(timeIntervalSinceNow: 8 * 60 * 60)) \(file.components(separatedBy: "/").last ?? "") \(function) \(line): \(msg)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
func xsLog(_ msg: Any?) { }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public func xs_swizzled_instanceMethod(_ prefix: String, oldClass: Swift.AnyClass!, oldSelector: String, newClass: Swift.AnyClass) {
|
||||||
|
let newSelector = prefix + "_" + oldSelector;
|
||||||
|
let originalSelector = NSSelectorFromString(oldSelector)
|
||||||
|
let swizzledSelector = NSSelectorFromString(newSelector)
|
||||||
|
|
||||||
|
let originalMethod = class_getInstanceMethod(oldClass, originalSelector)
|
||||||
|
let swizzledMethod = class_getInstanceMethod(newClass, swizzledSelector)
|
||||||
|
|
||||||
|
let isAdd = class_addMethod(oldClass, originalSelector, method_getImplementation(swizzledMethod!), method_getTypeEncoding(swizzledMethod!))
|
||||||
|
|
||||||
|
if isAdd {
|
||||||
|
class_replaceMethod(newClass, swizzledSelector, method_getImplementation(originalMethod!), method_getTypeEncoding(originalMethod!))
|
||||||
|
}else {
|
||||||
|
method_exchangeImplementations(originalMethod!, swizzledMethod!)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
XSeri/Base/Constants/XSScreen.swift
Normal file
51
XSeri/Base/Constants/XSScreen.swift
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
//
|
||||||
|
// XSScreen.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct XSScreen {
|
||||||
|
static var screen: UIScreen {
|
||||||
|
return UIScreen.main
|
||||||
|
}
|
||||||
|
|
||||||
|
static var width: CGFloat {
|
||||||
|
return UIScreen.main.bounds.width
|
||||||
|
}
|
||||||
|
|
||||||
|
static var height: CGFloat {
|
||||||
|
return UIScreen.main.bounds.height
|
||||||
|
}
|
||||||
|
|
||||||
|
static var safeTop: CGFloat {
|
||||||
|
return XSTool.keyWindow?.safeAreaInsets.top ?? 20
|
||||||
|
}
|
||||||
|
|
||||||
|
static var safeBottom: CGFloat {
|
||||||
|
return XSTool.keyWindow?.safeAreaInsets.bottom ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
static var navBarHeight: CGFloat {
|
||||||
|
return safeTop + 44
|
||||||
|
}
|
||||||
|
|
||||||
|
static var tabBarHeight: CGFloat {
|
||||||
|
return safeBottom + 49
|
||||||
|
}
|
||||||
|
|
||||||
|
static var customTabBarHeight: CGFloat {
|
||||||
|
return safeBottom + 60
|
||||||
|
}
|
||||||
|
|
||||||
|
///屏幕宽比
|
||||||
|
static var widthRatio: CGFloat {
|
||||||
|
return width / 375
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getRatioWidth(size: CGFloat) -> CGFloat {
|
||||||
|
return self.widthRatio * size
|
||||||
|
}
|
||||||
|
}
|
||||||
11
XSeri/Base/Constants/XSUserDefaultsKey.swift
Normal file
11
XSeri/Base/Constants/XSUserDefaultsKey.swift
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
//
|
||||||
|
// XSUserDefaultsKey.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
///登录token
|
||||||
|
let kXSLoginTokenDefaultsKey = "kXSLoginTokenDefaultsKey"
|
||||||
|
///用户信息
|
||||||
|
let kXSUserInfoDefaultsKey = "kXSUserInfoDefaultsKey"
|
||||||
32
XSeri/Base/Controller/XSCommonViewController.swift
Normal file
32
XSeri/Base/Controller/XSCommonViewController.swift
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
//
|
||||||
|
// XSCommonViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSCommonViewController: XSViewController {
|
||||||
|
|
||||||
|
|
||||||
|
private(set) lazy var bgImageView: UIImageView = {
|
||||||
|
let imageView = UIImageView(image: UIImage(named: "bg_image_01"))
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .black
|
||||||
|
view.addSubview(bgImageView)
|
||||||
|
|
||||||
|
bgImageView.snp.makeConstraints { make in
|
||||||
|
make.left.right.top.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
47
XSeri/Base/Controller/XSNavigationController.swift
Normal file
47
XSeri/Base/Controller/XSNavigationController.swift
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// XSNavigationController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import FDFullscreenPopGesture
|
||||||
|
|
||||||
|
class XSNavigationController: UINavigationController {
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
self.interactiveContentPopGestureRecognizer?.isEnabled = false
|
||||||
|
}
|
||||||
|
fd_fullscreenPopGestureRecognizer.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
|
||||||
|
if children.count > 0 {
|
||||||
|
viewController.hidesBottomBarWhenPushed = true
|
||||||
|
}
|
||||||
|
super.pushViewController(viewController, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
|
||||||
|
for (index, value) in viewControllers.enumerated() {
|
||||||
|
if index != 0 {
|
||||||
|
value.hidesBottomBarWhenPushed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.setViewControllers(viewControllers, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override var childForStatusBarStyle: UIViewController? {
|
||||||
|
return self.topViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
override var childForStatusBarHidden: UIViewController? {
|
||||||
|
return self.topViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
101
XSeri/Base/Controller/XSTabBarController.swift
Normal file
101
XSeri/Base/Controller/XSTabBarController.swift
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
//
|
||||||
|
// XSTabBarController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import ESTabBarController_swift
|
||||||
|
|
||||||
|
class XSTabBarController: ESTabBarController {
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
self.view.backgroundColor = .black
|
||||||
|
|
||||||
|
setupCustomTabBar()
|
||||||
|
setupTabBarAppearance()
|
||||||
|
setupViewControllers()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用自定义 TabBar,从根源控制高度(系统会根据 sizeThatFits 进行布局)
|
||||||
|
private func setupCustomTabBar() {
|
||||||
|
let tabBar = XSCustomTabBar()
|
||||||
|
tabBar.delegate = self
|
||||||
|
self.setValue(tabBar, forKey: "tabBar")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 配置 TabBar 外观样式
|
||||||
|
private func setupTabBarAppearance() {
|
||||||
|
// 设置背景颜色
|
||||||
|
self.tabBar.backgroundColor = XSConfig.Color.tabBarBg
|
||||||
|
|
||||||
|
// 设置顶部分割线颜色 (使用 XSConfig 中的 white 4%)
|
||||||
|
self.tabBar.shadowImage = UIImage()
|
||||||
|
self.tabBar.backgroundImage = UIImage()
|
||||||
|
|
||||||
|
// 设置 TabBar 容器样式:顶部 20pt 圆角
|
||||||
|
self.tabBar.layer.cornerRadius = 20
|
||||||
|
self.tabBar.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
|
self.tabBar.clipsToBounds = true
|
||||||
|
|
||||||
|
// 添加顶部分割线视图,因为 ESTabBar 可能会覆盖自定义 shadow
|
||||||
|
let line = UIView()
|
||||||
|
line.backgroundColor = XSConfig.Color.tabBarSeparator
|
||||||
|
self.tabBar.addSubview(line)
|
||||||
|
line.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化子控制器
|
||||||
|
private func setupViewControllers() {
|
||||||
|
let homeVC = createNavController(
|
||||||
|
rootVC: XSHomeViewController(),
|
||||||
|
title: "Home".localized,
|
||||||
|
imageName: "tab_home_unsel",
|
||||||
|
selectedImageName: "tab_home_sel"
|
||||||
|
)
|
||||||
|
|
||||||
|
let discoverVC = createNavController(
|
||||||
|
rootVC: XSDiscoverViewController(),
|
||||||
|
title: "Discover".localized,
|
||||||
|
imageName: "tab_discover_unsel",
|
||||||
|
selectedImageName: "tab_discover_sel"
|
||||||
|
)
|
||||||
|
|
||||||
|
let myListVC = createNavController(
|
||||||
|
rootVC: XSMyListViewController(),
|
||||||
|
title: "My List".localized,
|
||||||
|
imageName: "tab_list_unsel",
|
||||||
|
selectedImageName: "tab_list_sel"
|
||||||
|
)
|
||||||
|
|
||||||
|
let portfolioVC = createNavController(
|
||||||
|
rootVC: XSMineViewController(),
|
||||||
|
title: "Portfolio".localized,
|
||||||
|
imageName: "tab_portfolio_unsel",
|
||||||
|
selectedImageName: "tab_portfolio_sel"
|
||||||
|
)
|
||||||
|
|
||||||
|
viewControllers = [homeVC, discoverVC, myListVC, portfolioVC]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建导航控制器并配置 ESTabBarItem
|
||||||
|
private func createNavController(rootVC: UIViewController, title: String, imageName: String, selectedImageName: String) -> UINavigationController {
|
||||||
|
|
||||||
|
let nav = XSNavigationController(rootViewController: rootVC)
|
||||||
|
|
||||||
|
let itemContents = XSTabBarItemContentView()
|
||||||
|
|
||||||
|
// 配置图标和标题
|
||||||
|
let item = ESTabBarItem(
|
||||||
|
itemContents,
|
||||||
|
title: title,
|
||||||
|
image: UIImage(named: imageName),
|
||||||
|
selectedImage: UIImage(named: selectedImageName)
|
||||||
|
)
|
||||||
|
|
||||||
|
nav.tabBarItem = item
|
||||||
|
return nav
|
||||||
|
}
|
||||||
|
}
|
||||||
94
XSeri/Base/Controller/XSViewController.swift
Normal file
94
XSeri/Base/Controller/XSViewController.swift
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
//
|
||||||
|
// XSViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import JXSegmentedView
|
||||||
|
|
||||||
|
class XSViewController: UIViewController {
|
||||||
|
|
||||||
|
var backImage: UIImage? {
|
||||||
|
return UIImage(named: "arrow_left_icon_03")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = ._0_F_0_F_0_F
|
||||||
|
|
||||||
|
if let navi = navigationController {
|
||||||
|
if navi.visibleViewController == self {
|
||||||
|
if navi.viewControllers.count > 1 {
|
||||||
|
configNavigationBack(image: backImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleHeaderRefresh(_ completer: (() -> Void)?) {
|
||||||
|
completer?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleFooterRefresh(_ completer: (() -> Void)?) {
|
||||||
|
completer?()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIViewController {
|
||||||
|
func configNavigationBack(image: UIImage?) {
|
||||||
|
if let image = image {
|
||||||
|
let leftBarButtonItem = UIBarButtonItem(image: image, style: .plain ,target: self,action: #selector(handleNavigationBack))
|
||||||
|
navigationItem.leftBarButtonItem = leftBarButtonItem
|
||||||
|
} else {
|
||||||
|
navigationItem.leftBarButtonItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func handleNavigationBack() {
|
||||||
|
self.xs_toLastViewController(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_toLastViewController(animated: Bool) {
|
||||||
|
if self.navigationController != nil
|
||||||
|
{
|
||||||
|
if self.navigationController?.viewControllers.count == 1
|
||||||
|
{
|
||||||
|
self.dismiss(animated: animated, completion: nil)
|
||||||
|
} else {
|
||||||
|
self.navigationController?.popViewController(animated: animated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if self.presentingViewController != nil {
|
||||||
|
self.dismiss(animated: animated, completion: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//MARK: JXSegmentedListContainerViewListDelegate
|
||||||
|
extension XSViewController: JXSegmentedListContainerViewListDelegate {
|
||||||
|
func listView() -> UIView {
|
||||||
|
return self.view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIViewController {
|
||||||
|
|
||||||
|
func xs_setNavigationStyle(backgroundColor: UIColor = .clear,
|
||||||
|
titleFont: UIFont = .font(ofSize: 18, weight: .bold),
|
||||||
|
titleColor: UIColor = .white,
|
||||||
|
isTranslucent: Bool = true
|
||||||
|
) {
|
||||||
|
self.navigationController?.navigationBar.xs_setTranslucent(isTranslucent)
|
||||||
|
self.navigationController?.navigationBar.xs_setBackgroundColor(backgroundColor)
|
||||||
|
self.navigationController?.navigationBar.xs_setTitleTextAttributes([
|
||||||
|
NSAttributedString.Key.font : titleFont,
|
||||||
|
NSAttributedString.Key.foregroundColor : titleColor
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
65
XSeri/Base/Extension/CGMutablePath+XS.swift
Normal file
65
XSeri/Base/Extension/CGMutablePath+XS.swift
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
//
|
||||||
|
// CGMutablePath+XS.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct XSRoundedCorner {
|
||||||
|
var topLeft:CGFloat = 0
|
||||||
|
var topRight:CGFloat = 0
|
||||||
|
var bottomLeft:CGFloat = 0
|
||||||
|
var bottomRight:CGFloat = 0
|
||||||
|
|
||||||
|
public static let zero = XSRoundedCorner(topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0)
|
||||||
|
public init(topLeft: CGFloat, topRight:CGFloat, bottomLeft:CGFloat, bottomRight:CGFloat) {
|
||||||
|
self.topLeft = topLeft
|
||||||
|
self.topRight = topRight
|
||||||
|
self.bottomLeft = bottomLeft
|
||||||
|
self.bottomRight = bottomRight
|
||||||
|
}
|
||||||
|
static func ==(v1:XSRoundedCorner, v2:XSRoundedCorner) -> Bool {
|
||||||
|
return v1.bottomLeft == v2.bottomLeft
|
||||||
|
&& v1.bottomRight == v2.bottomRight
|
||||||
|
&& v1.topLeft == v2.topLeft
|
||||||
|
&& v1.topRight == v2.topRight
|
||||||
|
}
|
||||||
|
static func !=(v1:XSRoundedCorner, v2:XSRoundedCorner) -> Bool {
|
||||||
|
return !(v1 == v2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CGMutablePath {
|
||||||
|
func addRadiusRectangle(_ circulars: XSRoundedCorner, rect: CGRect) {
|
||||||
|
let minX = rect.minX
|
||||||
|
let minY = rect.minY
|
||||||
|
let maxX = rect.maxX
|
||||||
|
let maxY = rect.maxY
|
||||||
|
|
||||||
|
//获取四个圆心
|
||||||
|
let topLeftCenterX = minX + circulars.topLeft
|
||||||
|
let topLeftCenterY = minY + circulars.topLeft
|
||||||
|
|
||||||
|
let topRightCenterX = maxX - circulars.topRight
|
||||||
|
let topRightCenterY = minY + circulars.topRight
|
||||||
|
|
||||||
|
let bottomLeftCenterX = minX + circulars.bottomLeft
|
||||||
|
let bottomLeftCenterY = maxY - circulars.bottomLeft
|
||||||
|
|
||||||
|
let bottomRightCenterX = maxX - circulars.bottomRight
|
||||||
|
let bottomRightCenterY = maxY - circulars.bottomRight
|
||||||
|
|
||||||
|
//顶 左
|
||||||
|
addArc(center: CGPoint(x: topLeftCenterX, y: topLeftCenterY), radius: circulars.topLeft, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 3 / 2, clockwise: false)
|
||||||
|
//顶右
|
||||||
|
addArc(center: CGPoint(x: topRightCenterX, y: topRightCenterY), radius: circulars.topRight, startAngle: CGFloat.pi * 3 / 2, endAngle: 0, clockwise: false)
|
||||||
|
//底右
|
||||||
|
addArc(center: CGPoint(x: bottomRightCenterX, y: bottomRightCenterY), radius: circulars.bottomRight, startAngle: 0, endAngle: CGFloat.pi / 2, clockwise: false)
|
||||||
|
//底左
|
||||||
|
addArc(center: CGPoint(x: bottomLeftCenterX, y: bottomLeftCenterY), radius: circulars.bottomLeft, startAngle: CGFloat.pi / 2, endAngle: CGFloat.pi, clockwise: false)
|
||||||
|
closeSubpath();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
23
XSeri/Base/Extension/Dictionary+XS.swift
Normal file
23
XSeri/Base/Extension/Dictionary+XS.swift
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// Dictionary+XS.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/28.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension Dictionary {
|
||||||
|
|
||||||
|
func toJsonString() -> String? {
|
||||||
|
do {
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: self)
|
||||||
|
let jsonStr = String(data: data, encoding: .utf8)
|
||||||
|
return jsonStr
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
35
XSeri/Base/Extension/NSNumber+XS.swift
Normal file
35
XSeri/Base/Extension/NSNumber+XS.swift
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
//
|
||||||
|
// NSNumber+XS.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension NSNumber {
|
||||||
|
|
||||||
|
func toString(maximumFractionDigits: Int = 10, minimumFractionDigits: Int? = nil, roundingMode: NumberFormatter.RoundingMode? = nil) -> String {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.minimumIntegerDigits = 1
|
||||||
|
formatter.maximumFractionDigits = maximumFractionDigits
|
||||||
|
if let minimumFractionDigits = minimumFractionDigits {
|
||||||
|
formatter.minimumFractionDigits = minimumFractionDigits
|
||||||
|
}
|
||||||
|
if let roundingMode = roundingMode {
|
||||||
|
formatter.roundingMode = roundingMode
|
||||||
|
}
|
||||||
|
formatter.numberStyle = .none
|
||||||
|
return formatter.string(from: self) ?? "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
func format() -> String {
|
||||||
|
let num = self.floatValue
|
||||||
|
|
||||||
|
if num > 1000 {
|
||||||
|
return NSNumber(value: num / 1000).toString(maximumFractionDigits: 1) + "K"
|
||||||
|
} else {
|
||||||
|
return self.stringValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
XSeri/Base/Extension/String+XS.swift
Normal file
30
XSeri/Base/Extension/String+XS.swift
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
// String+XS.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SmartCodable
|
||||||
|
|
||||||
|
extension String: SmartCodable {
|
||||||
|
|
||||||
|
var localized: String {
|
||||||
|
var text = NSLocalizedString(self, comment: "")
|
||||||
|
text = text.replacingOccurrences(of: "<br>", with: "\n")
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func localizedReplace(text: String?) -> String {
|
||||||
|
return self.localized.replacingOccurrences(of: "##", with: text ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var length:Int { return (self as NSString).length }
|
||||||
|
|
||||||
|
func size(_ font: UIFont, _ size: CGSize) -> CGSize {
|
||||||
|
return (self as NSString).size(for: font, size: size, mode: .byWordWrapping)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
XSeri/Base/Extension/UIFont+XS.swift
Normal file
16
XSeri/Base/Extension/UIFont+XS.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// UIFont+XS.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIFont {
|
||||||
|
|
||||||
|
static func font(ofSize fontSize: CGFloat, weight: Weight) -> UIFont {
|
||||||
|
return UIFont.systemFont(ofSize: fontSize, weight: weight)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
49
XSeri/Base/Extension/UINavigationBar+XS.swift
Normal file
49
XSeri/Base/Extension/UINavigationBar+XS.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// UINavigationBar+XS.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
|
||||||
|
extension UINavigationBarAppearance {
|
||||||
|
static func defaultAppearance() -> UINavigationBarAppearance {
|
||||||
|
let navBarAppearance = UINavigationBarAppearance()
|
||||||
|
navBarAppearance.configureWithOpaqueBackground()
|
||||||
|
navBarAppearance.backgroundColor = .clear
|
||||||
|
navBarAppearance.backgroundEffect = nil
|
||||||
|
navBarAppearance.shadowColor = UIColor.clear
|
||||||
|
navBarAppearance.titleTextAttributes = [
|
||||||
|
NSAttributedString.Key.font : UIFont.font(ofSize: 18, weight: .bold),
|
||||||
|
NSAttributedString.Key.foregroundColor : UIColor.white
|
||||||
|
]
|
||||||
|
return navBarAppearance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UINavigationBar {
|
||||||
|
|
||||||
|
func xs_setTranslucent(_ isTranslucent: Bool) {
|
||||||
|
self.isTranslucent = isTranslucent
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_setBackgroundColor(_ backgroundColor: UIColor?) {
|
||||||
|
let appearance = self.standardAppearance
|
||||||
|
appearance.backgroundColor = backgroundColor
|
||||||
|
self.standardAppearance = appearance
|
||||||
|
self.scrollEdgeAppearance = appearance
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_setTitleTextAttributes(_ titleTextAttributes: [NSAttributedString.Key : Any]?) {
|
||||||
|
let appearance = self.standardAppearance
|
||||||
|
|
||||||
|
if let titleTextAttributes = titleTextAttributes {
|
||||||
|
appearance.titleTextAttributes = titleTextAttributes
|
||||||
|
}
|
||||||
|
self.scrollEdgeAppearance = appearance
|
||||||
|
self.standardAppearance = appearance
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
34
XSeri/Base/Extension/UIScrollView+Refresh.swift
Normal file
34
XSeri/Base/Extension/UIScrollView+Refresh.swift
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
//
|
||||||
|
// UIScrollView+Refresh.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import MJRefresh
|
||||||
|
|
||||||
|
extension UIScrollView {
|
||||||
|
|
||||||
|
func xs_addRefreshHeader(insetTop: CGFloat = 0, block: (() -> Void)?) {
|
||||||
|
|
||||||
|
self.mj_header = MJRefreshNormalHeader(refreshingBlock: {
|
||||||
|
block?()
|
||||||
|
})
|
||||||
|
self.mj_header?.ignoredScrollViewContentInsetTop = insetTop
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_addRefreshFooter(insetBottom: CGFloat = 0, block: (() -> Void)?) {
|
||||||
|
self.mj_footer = MJRefreshBackNormalFooter(refreshingBlock: {
|
||||||
|
block?()
|
||||||
|
})
|
||||||
|
self.mj_footer?.ignoredScrollViewContentInsetBottom = insetBottom
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_endHeaderRefreshing() {
|
||||||
|
self.mj_header?.endRefreshing()
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_endFooterRefreshing() {
|
||||||
|
self.mj_footer?.endRefreshing()
|
||||||
|
}
|
||||||
|
}
|
||||||
22
XSeri/Base/Extension/UIStackView+XS.swift
Normal file
22
XSeri/Base/Extension/UIStackView+XS.swift
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// UIStackView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIStackView {
|
||||||
|
|
||||||
|
func xs_removeAllArrangedSubview() {
|
||||||
|
let arrangedSubviews = self.arrangedSubviews
|
||||||
|
|
||||||
|
arrangedSubviews.forEach {
|
||||||
|
self.removeArrangedSubview($0)
|
||||||
|
$0.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
65
XSeri/Base/Extension/UIView+XS.swift
Normal file
65
XSeri/Base/Extension/UIView+XS.swift
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
//
|
||||||
|
// UIView+XS.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIView {
|
||||||
|
|
||||||
|
fileprivate struct AssociatedKeys {
|
||||||
|
static var xs_roundedCorner: Int?
|
||||||
|
static var xs_effect: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc public static func xs_awake() {
|
||||||
|
xs_swizzled_instanceMethod("xs", oldClass: self, oldSelector: "layoutSubviews", newClass: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func xs_layoutSubviews() {
|
||||||
|
xs_layoutSubviews()
|
||||||
|
|
||||||
|
xs_updateRoundedCorner()
|
||||||
|
//
|
||||||
|
// if let effectView = effectView, effectView.frame != self.bounds {
|
||||||
|
// effectView.frame = self.bounds
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: -------------- 圆角 --------------
|
||||||
|
extension UIView {
|
||||||
|
|
||||||
|
|
||||||
|
private var roundedCorner: XSRoundedCorner? {
|
||||||
|
get {
|
||||||
|
return objc_getAssociatedObject(self, &AssociatedKeys.xs_roundedCorner) as? XSRoundedCorner
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
objc_setAssociatedObject(self, &AssociatedKeys.xs_roundedCorner, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
///设置圆角
|
||||||
|
func xs_setCornerRadius(topLeft: CGFloat, topRight: CGFloat, bottomLeft: CGFloat, bottomRight: CGFloat) {
|
||||||
|
//清空其它设置方法
|
||||||
|
self.roundedCorner = XSRoundedCorner(topLeft: topLeft, topRight: topRight, bottomLeft: bottomLeft, bottomRight: bottomRight)
|
||||||
|
xs_updateRoundedCorner()
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_updateRoundedCorner() {
|
||||||
|
guard let roundedCorner = self.roundedCorner else { return }
|
||||||
|
let rect = self.bounds
|
||||||
|
|
||||||
|
let path = CGMutablePath()
|
||||||
|
path.addRadiusRectangle(roundedCorner, rect: rect)
|
||||||
|
|
||||||
|
let maskLayer = CAShapeLayer()
|
||||||
|
maskLayer.frame = self.bounds
|
||||||
|
maskLayer.path = path
|
||||||
|
self.layer.mask = maskLayer
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
44
XSeri/Base/Extension/UserDefaults+XS.swift
Normal file
44
XSeri/Base/Extension/UserDefaults+XS.swift
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// UserDefaults+XS.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
|
||||||
|
extension UserDefaults {
|
||||||
|
|
||||||
|
static func xs_setObject(_ obj: NSSecureCoding?, forKey key: String) {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
guard let obj = obj else {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try NSKeyedArchiver.archivedData(withRootObject: obj, requiringSecureCoding: true)
|
||||||
|
defaults.set(data, forKey: key)
|
||||||
|
} catch {
|
||||||
|
print("\(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func xs_object<T: NSObject & NSSecureCoding>(forKey key: String, as type: T.Type) -> T? {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
guard let data = defaults.data(forKey: key) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let object = try NSKeyedUnarchiver.unarchivedObject(ofClass: type, from: data)
|
||||||
|
return object
|
||||||
|
} catch {
|
||||||
|
print("\(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
XSeri/Base/Networking/API/XSHomeAPI.swift
Normal file
105
XSeri/Base/Networking/API/XSHomeAPI.swift
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
//
|
||||||
|
// XSHomeAPI.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct XSHomeAPI {
|
||||||
|
|
||||||
|
static func requestHomeData() async -> [XSHomeModuleItem]? {
|
||||||
|
var parameters = XSNetwork.Parameters(path: "/home/all-modules")
|
||||||
|
parameters.method = .get
|
||||||
|
parameters.isToast = true
|
||||||
|
let response: XSNetwork.Response<XSNetwork.List<XSHomeModuleItem>> = await XSNetwork.request(parameters: parameters)
|
||||||
|
return response.data?.list
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestHomeNew(page: Int) async -> [XSShortModel]? {
|
||||||
|
var parameters = XSNetwork.Parameters(path: "/newShortPlayList")
|
||||||
|
parameters.method = .get
|
||||||
|
parameters.parameters = [
|
||||||
|
"current_page" : page,
|
||||||
|
"page_size" : 20
|
||||||
|
]
|
||||||
|
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: parameters)
|
||||||
|
return response.data?.list
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestHomeRankings() async -> [XSShortModel]? {
|
||||||
|
var parameters = XSNetwork.Parameters(path: "/nDayMaxRechargeShortPlayRank")
|
||||||
|
parameters.method = .get
|
||||||
|
parameters.parameters = [
|
||||||
|
"day" : 30,
|
||||||
|
]
|
||||||
|
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: parameters)
|
||||||
|
return response.data?.list
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestCategoryList() async -> [XSCategoryModel]? {
|
||||||
|
var parameters = XSNetwork.Parameters(path: "/getCategories")
|
||||||
|
parameters.method = .get
|
||||||
|
let response: XSNetwork.Response<XSNetwork.List<XSCategoryModel>> = await XSNetwork.request(parameters: parameters)
|
||||||
|
return response.data?.list
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestCategoryVideo(id: String, page: Int) async -> [XSShortModel]? {
|
||||||
|
var parameters = XSNetwork.Parameters(path: "/videoList")
|
||||||
|
parameters.method = .get
|
||||||
|
parameters.parameters = [
|
||||||
|
"category_id" : id,
|
||||||
|
"current_page" : page,
|
||||||
|
"page_size" : 20
|
||||||
|
]
|
||||||
|
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: parameters)
|
||||||
|
return response.data?.list
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestDiscoverData(page: Int) async -> [XSShortModel]? {
|
||||||
|
|
||||||
|
var parameters = XSNetwork.Parameters(path: "/getRecommands")
|
||||||
|
parameters.method = .get
|
||||||
|
parameters.parameters = [
|
||||||
|
"current_page" : page,
|
||||||
|
"page_size" : 20
|
||||||
|
]
|
||||||
|
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: parameters)
|
||||||
|
return response.data?.list
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
///搜索
|
||||||
|
static func requestSearch(text: String) async -> [XSShortModel]? {
|
||||||
|
var parameters = XSNetwork.Parameters(path: "/search")
|
||||||
|
parameters.method = .get
|
||||||
|
parameters.parameters = [
|
||||||
|
"search" : text
|
||||||
|
]
|
||||||
|
|
||||||
|
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: parameters)
|
||||||
|
return response.data?.list
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestHotSearchList() async -> [XSShortModel]? {
|
||||||
|
var parameters = XSNetwork.Parameters(path: "/search/hots")
|
||||||
|
parameters.method = .get
|
||||||
|
parameters.isLoding = false
|
||||||
|
parameters.isToast = false
|
||||||
|
|
||||||
|
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: parameters)
|
||||||
|
return response.data?.list
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestTopSearchList() async -> [XSShortModel]? {
|
||||||
|
var parameters = XSNetwork.Parameters(path: "/getVisitTop")
|
||||||
|
parameters.method = .get
|
||||||
|
parameters.isLoding = false
|
||||||
|
parameters.isToast = false
|
||||||
|
|
||||||
|
let response: XSNetwork.Response<[XSShortModel]> = await XSNetwork.request(parameters: parameters)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
20
XSeri/Base/Networking/API/XSSettingAPI.swift
Normal file
20
XSeri/Base/Networking/API/XSSettingAPI.swift
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// XSSettingAPI.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/28.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
struct XSSettingAPI {
|
||||||
|
|
||||||
|
static func requestFeedbackRedCount() async -> XSFeedbackCountModel? {
|
||||||
|
|
||||||
|
var param = XSNetwork.Parameters(path: "/noticeNum")
|
||||||
|
param.method = .post
|
||||||
|
param.isLoding = false
|
||||||
|
param.isToast = false
|
||||||
|
let response: XSNetwork.Response<XSFeedbackCountModel> = await XSNetwork.request(parameters: param)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
20
XSeri/Base/Networking/API/XSUserAPI.swift
Normal file
20
XSeri/Base/Networking/API/XSUserAPI.swift
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// XSUserAPI.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
|
||||||
|
struct XSUserAPI {
|
||||||
|
|
||||||
|
static func requestUserInfo() async -> XSUserInfo? {
|
||||||
|
|
||||||
|
var parameters = XSNetwork.Parameters(path: "/customer/info")
|
||||||
|
parameters.method = .get
|
||||||
|
|
||||||
|
let response: XSNetwork.Response<XSUserInfo> = await XSNetwork.request(parameters: parameters)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
}
|
||||||
116
XSeri/Base/Networking/API/XSVideoAPI.swift
Normal file
116
XSeri/Base/Networking/API/XSVideoAPI.swift
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
//
|
||||||
|
// XSVideoAPI.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
|
||||||
|
struct XSVideoAPI {
|
||||||
|
|
||||||
|
static func requestShortDetail(shortPlayId: String) async -> (XSShortDetailModel?, Int?, String?) {
|
||||||
|
let parameters: [String : Any] = [
|
||||||
|
"short_play_id" : shortPlayId,
|
||||||
|
"video_id" : "0"
|
||||||
|
]
|
||||||
|
var param = XSNetwork.Parameters(path: "/getVideoDetails")
|
||||||
|
param.method = .get
|
||||||
|
param.isToast = true
|
||||||
|
param.parameters = parameters
|
||||||
|
let response: XSNetwork.Response<XSShortDetailModel> = await XSNetwork.request(parameters: param)
|
||||||
|
if response.isSuccess {
|
||||||
|
return (response.data, response.code, response.msg)
|
||||||
|
} else {
|
||||||
|
return (nil, response.code, response.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestAddPlayHistory(shortPlayId: String?, videoId: String?) async {
|
||||||
|
let parameters: [String : Any] = [
|
||||||
|
"short_play_id" : shortPlayId ?? "0",
|
||||||
|
"video_id" : videoId ?? "0"
|
||||||
|
]
|
||||||
|
var param = XSNetwork.Parameters(path: "/createHistory")
|
||||||
|
param.method = .post
|
||||||
|
param.isToast = false
|
||||||
|
param.isLoding = false
|
||||||
|
param.parameters = parameters
|
||||||
|
let _: XSNetwork.Response<String> = await XSNetwork.request(parameters: param)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestPlayHistorys(page: Int, pageSize: Int = 20) async -> [XSShortModel]? {
|
||||||
|
let parameters: [String : Any] = [
|
||||||
|
"current_page" : page,
|
||||||
|
"page_size" : pageSize
|
||||||
|
]
|
||||||
|
var param = XSNetwork.Parameters(path: "/myHistorys")
|
||||||
|
param.method = .get
|
||||||
|
param.parameters = parameters
|
||||||
|
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: param)
|
||||||
|
if response.isSuccess {
|
||||||
|
return response.data?.list
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestCollectShort(isCollect: Bool, shortId: String, videoId: String?) async -> Bool {
|
||||||
|
let path: String
|
||||||
|
if isCollect {
|
||||||
|
path = "/collect"
|
||||||
|
} else {
|
||||||
|
path = "/cancelCollect"
|
||||||
|
}
|
||||||
|
|
||||||
|
var parameters: [String : Any] = [
|
||||||
|
"short_play_id" : shortId,
|
||||||
|
]
|
||||||
|
|
||||||
|
if let videoId = videoId {
|
||||||
|
parameters["video_id"] = videoId
|
||||||
|
}
|
||||||
|
|
||||||
|
var param = XSNetwork.Parameters(path: path)
|
||||||
|
param.method = .post
|
||||||
|
param.isLoding = true
|
||||||
|
param.parameters = parameters
|
||||||
|
let response: XSNetwork.Response<String> = await XSNetwork.request(parameters: param)
|
||||||
|
if response.isSuccess {
|
||||||
|
await MainActor.run {
|
||||||
|
NotificationCenter.default.post(name: XSVideoAPI.updateShortCollectStateNotification, object: nil, userInfo: [
|
||||||
|
"state" : isCollect,
|
||||||
|
"id" : shortId,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestCollectList(page: Int, pageSize: Int = 20) async -> [XSShortModel]? {
|
||||||
|
let parameters: [String : Any] = [
|
||||||
|
"current_page" : page,
|
||||||
|
"page_size" : pageSize
|
||||||
|
]
|
||||||
|
var param = XSNetwork.Parameters(path: "/myCollections")
|
||||||
|
param.method = .get
|
||||||
|
param.parameters = parameters
|
||||||
|
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: param)
|
||||||
|
if response.isSuccess {
|
||||||
|
return response.data?.list
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSVideoAPI {
|
||||||
|
|
||||||
|
///更新短剧关注状态 [ "state" : isFavorite, "id" : shortPlayId,]
|
||||||
|
static let updateShortCollectStateNotification = Notification.Name(rawValue: "XSVideoAPI.updateShortCollectStateNotification")
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
102
XSeri/Base/Networking/Base/XSCryptorService.swift
Normal file
102
XSeri/Base/Networking/Base/XSCryptorService.swift
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
//
|
||||||
|
// XSCryptorService.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct XSCryptorService {
|
||||||
|
|
||||||
|
// 定义常量
|
||||||
|
static let EN_STR_TAG: String = "$" // 替换为实际的加密标记
|
||||||
|
|
||||||
|
// 解密字符串
|
||||||
|
static func decrypt(data: String) -> String {
|
||||||
|
guard data.hasPrefix(EN_STR_TAG) else {
|
||||||
|
// fatalError("Invalid encoded string")
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
let decryptedData = deStrBytes(data: data)
|
||||||
|
return String(data: decryptedData, encoding: .utf8) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从十六进制字符串解密字节
|
||||||
|
static func deStrBytes(data: String) -> Data {
|
||||||
|
|
||||||
|
let hexData = String(data.dropFirst())
|
||||||
|
var bytes = Data()
|
||||||
|
|
||||||
|
var index = hexData.startIndex
|
||||||
|
while index < hexData.endIndex {
|
||||||
|
let nextIndex = hexData.index(index, offsetBy: 2, limitedBy: hexData.endIndex) ?? hexData.endIndex
|
||||||
|
let byteString = String(hexData[index..<nextIndex])
|
||||||
|
|
||||||
|
if let byte = UInt8(byteString, radix: 16) {
|
||||||
|
bytes.append(byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
index = nextIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
return de(data: bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密数据
|
||||||
|
static func de(data: Data) -> Data {
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
let saltLen = Int(data[data.startIndex])
|
||||||
|
guard data.count >= 1 + saltLen else {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
let salt = data.subdata(in: 1..<1+saltLen)
|
||||||
|
let encryptedData = data.subdata(in: 1+saltLen..<data.count)
|
||||||
|
|
||||||
|
return deWithSalt(data: encryptedData, salt: salt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用盐值解密数据
|
||||||
|
static func deWithSalt(data: Data, salt: Data) -> Data {
|
||||||
|
let decryptedData = cxEd(data: data)
|
||||||
|
return removeSalt(data: decryptedData, salt: salt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密/解密数据(按位取反)
|
||||||
|
static func cxEd(data: Data) -> Data {
|
||||||
|
return Data(data.map { $0 ^ 0xFF })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从数据中移除盐值
|
||||||
|
static func removeSalt(data: Data, salt: Data) -> Data {
|
||||||
|
guard !salt.isEmpty else {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = Data()
|
||||||
|
let saltBytes = [UInt8](salt)
|
||||||
|
let saltCount = saltBytes.count
|
||||||
|
|
||||||
|
for (index, byte) in data.enumerated() {
|
||||||
|
let saltByte = saltBytes[index % saltCount]
|
||||||
|
let decryptedByte = calRemoveSalt(v: byte, s: saltByte)
|
||||||
|
result.append(decryptedByte)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算移除盐值后的字节
|
||||||
|
static func calRemoveSalt(v: UInt8, s: UInt8) -> UInt8 {
|
||||||
|
if v >= s {
|
||||||
|
return v - s
|
||||||
|
} else {
|
||||||
|
return UInt8(0xFF) - (s - v) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
186
XSeri/Base/Networking/Base/XSNetwork.swift
Normal file
186
XSeri/Base/Networking/Base/XSNetwork.swift
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
//
|
||||||
|
// XSNetwork.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Moya
|
||||||
|
import SmartCodable
|
||||||
|
|
||||||
|
let kXSRegisterPath = "/customer/register"
|
||||||
|
|
||||||
|
/// 网络请求核心类
|
||||||
|
struct XSNetwork {
|
||||||
|
|
||||||
|
/// Moya 提供者,注入 HUD 插件实现解耦
|
||||||
|
static let provider = MoyaProvider<XSNetwork.Target>(plugins: [XSNetworkHUDPlugin()])
|
||||||
|
|
||||||
|
/// 用于存储正在进行的 Token 刷新任务,防止并发竞争
|
||||||
|
/// 使用 _Concurrency.Task 明确指定系统并发任务,避免与 Moya.Task 冲突
|
||||||
|
private static var refreshTokenTask: _Concurrency.Task<Bool, Never>?
|
||||||
|
|
||||||
|
// MARK: - Public Request Method
|
||||||
|
|
||||||
|
/// 异步网络请求方法
|
||||||
|
/// - Parameter parameters: 请求参数配置
|
||||||
|
/// - Returns: 封装好的 Response 模型
|
||||||
|
static func request<T: SmartCodable>(parameters: XSNetwork.Parameters) async -> XSNetwork.Response<T> {
|
||||||
|
|
||||||
|
// 1. Token 检查 (非注册接口且无 Token 时,先尝试获取)
|
||||||
|
if XSLoginManager.manager.token == nil && parameters.path != kXSRegisterPath {
|
||||||
|
_ = await self.requestToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 执行请求
|
||||||
|
let result = await provider.requestAsync(.request(parameters: parameters))
|
||||||
|
|
||||||
|
// 3. 结果处理
|
||||||
|
switch result {
|
||||||
|
case .success(let response):
|
||||||
|
let code = response.statusCode
|
||||||
|
|
||||||
|
// 4. Token 失效处理 (401, 402, 403)
|
||||||
|
if (code == 401 || code == 402 || code == 403) && parameters.path != kXSRegisterPath {
|
||||||
|
|
||||||
|
// 如果是 402 且需要提示
|
||||||
|
if code == 402 && parameters.isToast {
|
||||||
|
await MainActor.run { XSToast.show("network_error_1".localized) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试刷新 Token
|
||||||
|
let success = await self.requestToken()
|
||||||
|
if success {
|
||||||
|
// 刷新成功后重试当前请求
|
||||||
|
return await self.request(parameters: parameters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 正常业务数据解析
|
||||||
|
return self.handleResponse(response, parameters: parameters)
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
// 6. 网络连接层错误处理
|
||||||
|
xsLog("Network Error: \(error)")
|
||||||
|
if parameters.isToast {
|
||||||
|
await MainActor.run { XSToast.show("network_error_2".localized) }
|
||||||
|
}
|
||||||
|
return self.createErrorResponse(msg: "network_error_2".localized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helper Methods
|
||||||
|
|
||||||
|
/// 处理响应数据
|
||||||
|
private static func handleResponse<T: SmartCodable>(_ response: Moya.Response, parameters: XSNetwork.Parameters) -> XSNetwork.Response<T> {
|
||||||
|
do {
|
||||||
|
let tempData = try response.mapString()
|
||||||
|
xsLog("Path: \(parameters.path)")
|
||||||
|
xsLog("Params: \(parameters.parameters ?? [:])")
|
||||||
|
|
||||||
|
let networkResponse: XSNetwork.Response<T> = self._deserialize(data: tempData)
|
||||||
|
|
||||||
|
// 业务逻辑错误提示 (例如 code != 200)
|
||||||
|
if !networkResponse.isSuccess && parameters.isToast {
|
||||||
|
let msg = networkResponse.msg ?? "Error".localized
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
XSToast.show(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
if parameters.isToast {
|
||||||
|
DispatchQueue.main.async { XSToast.show("Error".localized) }
|
||||||
|
}
|
||||||
|
return self.createErrorResponse(msg: "Error".localized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析与解密数据
|
||||||
|
private static func _deserialize<T: SmartCodable>(data: String) -> XSNetwork.Response<T> {
|
||||||
|
let decrypted = XSCryptorService.decrypt(data: data)
|
||||||
|
xsLog("Decrypted Response: \(decrypted)")
|
||||||
|
|
||||||
|
var response = XSNetwork.Response<T>.deserialize(from: decrypted)
|
||||||
|
response?.rawData = decrypted
|
||||||
|
|
||||||
|
if let response = response {
|
||||||
|
return response
|
||||||
|
} else {
|
||||||
|
return self.createErrorResponse(msg: "Error".localized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建错误响应对象
|
||||||
|
private static func createErrorResponse<T: SmartCodable>(code: Int = -1, msg: String) -> XSNetwork.Response<T> {
|
||||||
|
var res = XSNetwork.Response<T>()
|
||||||
|
res.code = code
|
||||||
|
res.msg = msg
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Token Management
|
||||||
|
|
||||||
|
/// 获取/刷新 Token
|
||||||
|
@discardableResult
|
||||||
|
@MainActor
|
||||||
|
static func requestToken() async -> Bool {
|
||||||
|
// 如果已经有一个刷新任务在进行,直接等待并返回它的结果
|
||||||
|
if let existingTask = refreshTokenTask {
|
||||||
|
return await existingTask.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的刷新任务,明确指定 _Concurrency.Task
|
||||||
|
let task = _Concurrency.Task { () -> Bool in
|
||||||
|
let param = XSNetwork.Parameters(path: kXSRegisterPath, isLoding: false, isToast: false)
|
||||||
|
let response: XSNetwork.Response<XSLoginToken> = await self.request(parameters: param)
|
||||||
|
|
||||||
|
if let token = response.data {
|
||||||
|
XSLoginManager.manager.setAccountToken(token)
|
||||||
|
_Concurrency.Task {
|
||||||
|
await XSLoginManager.manager.updateUserInfo()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
self.refreshTokenTask = task
|
||||||
|
|
||||||
|
// 等待任务完成
|
||||||
|
let result = await task.value
|
||||||
|
|
||||||
|
// 任务完成后重置,以便后续再次刷新
|
||||||
|
self.refreshTokenTask = nil
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Moya Async Extension
|
||||||
|
extension MoyaProvider {
|
||||||
|
/// 封装 Moya 为 Async/Await 风格
|
||||||
|
func requestAsync(_ target: Target) async -> Result<Moya.Response, MoyaError> {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
self.request(target) { result in
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Backward Compatibility
|
||||||
|
extension XSNetwork {
|
||||||
|
/// 保留旧的闭包回调方法,内部调用 async 方法,方便平滑迁移
|
||||||
|
static func request<T: SmartCodable>(parameters: XSNetwork.Parameters, completion: ((_ response: XSNetwork.Response<T>) -> Void)?) {
|
||||||
|
_Concurrency.Task {
|
||||||
|
let response: XSNetwork.Response<T> = await self.request(parameters: parameters)
|
||||||
|
await MainActor.run {
|
||||||
|
completion?(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
XSeri/Base/Networking/Base/XSNetworkModel.swift
Normal file
75
XSeri/Base/Networking/Base/XSNetworkModel.swift
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
//
|
||||||
|
// XSNetworkModel.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Moya
|
||||||
|
import SmartCodable
|
||||||
|
import Alamofire
|
||||||
|
|
||||||
|
extension XSNetwork {
|
||||||
|
|
||||||
|
/// 业务成功状态码
|
||||||
|
static let SuccessCode = 200
|
||||||
|
|
||||||
|
/// 请求参数配置模型
|
||||||
|
struct Parameters {
|
||||||
|
var path: String
|
||||||
|
var parameters: [String : Any]?
|
||||||
|
var method: Moya.Method = .post
|
||||||
|
var isLoding: Bool = false
|
||||||
|
var isToast: Bool = true
|
||||||
|
|
||||||
|
init(path: String,
|
||||||
|
parameters: [String : Any]? = nil,
|
||||||
|
method: Moya.Method = .post,
|
||||||
|
isLoding: Bool = false,
|
||||||
|
isToast: Bool = true) {
|
||||||
|
self.path = path
|
||||||
|
self.parameters = parameters
|
||||||
|
self.method = method
|
||||||
|
self.isLoding = isLoding
|
||||||
|
self.isToast = isToast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一响应模型
|
||||||
|
struct Response<T: SmartCodable>: SmartCodable {
|
||||||
|
var data: T?
|
||||||
|
var code: Int?
|
||||||
|
var msg: String?
|
||||||
|
|
||||||
|
/// 原始解密数据,用于特殊处理
|
||||||
|
@IgnoredKey
|
||||||
|
var rawData: Any?
|
||||||
|
|
||||||
|
/// 是否请求成功(基于业务状态码)
|
||||||
|
var isSuccess: Bool {
|
||||||
|
return code == XSNetwork.SuccessCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分页列表模型
|
||||||
|
struct List<T: SmartCodable>: SmartCodable {
|
||||||
|
var list: [T]?
|
||||||
|
var pagination: Pagination?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分页信息模型
|
||||||
|
struct Pagination: SmartCodable {
|
||||||
|
var current_page: Int?
|
||||||
|
var page_size: Int?
|
||||||
|
var page_total: Int?
|
||||||
|
var total_size: Int?
|
||||||
|
|
||||||
|
/// SmartCodable 默认支持下划线转驼峰,但显式定义更安全
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case current_page = "current_page"
|
||||||
|
case page_size = "page_size"
|
||||||
|
case page_total = "page_total"
|
||||||
|
case total_size = "total_size"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
XSeri/Base/Networking/Base/XSNetworkMonitorManager.swift
Normal file
76
XSeri/Base/Networking/Base/XSNetworkMonitorManager.swift
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
//
|
||||||
|
// XSNetworkMonitorManager.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Network
|
||||||
|
|
||||||
|
class XSNetworkMonitorManager: NSObject {
|
||||||
|
|
||||||
|
static let manager = XSNetworkMonitorManager()
|
||||||
|
|
||||||
|
///是否有网
|
||||||
|
var isReachable: Bool?
|
||||||
|
|
||||||
|
private var connectionType: NWInterface.InterfaceType?
|
||||||
|
private var status: NWPath.Status?
|
||||||
|
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let queue = DispatchQueue(label: "NetworkMonitorQueue")
|
||||||
|
|
||||||
|
func startMonitoring() {
|
||||||
|
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.status = path.status
|
||||||
|
|
||||||
|
if path.usesInterfaceType(.wifi) {
|
||||||
|
self.connectionType = .wifi
|
||||||
|
} else if path.usesInterfaceType(.cellular) {
|
||||||
|
self.connectionType = .cellular
|
||||||
|
} else if path.usesInterfaceType(.wiredEthernet) {
|
||||||
|
self.connectionType = .wiredEthernet
|
||||||
|
} else {
|
||||||
|
self.connectionType = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if path.status == .satisfied, self.connectionType != nil {
|
||||||
|
if self.isReachable == false {
|
||||||
|
self.isReachable = true
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: XSNetworkMonitorManager.networkStatusDidChangeNotification, object: nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.isReachable = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if self.isReachable == true {
|
||||||
|
self.isReachable = false
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: XSNetworkMonitorManager.networkStatusDidChangeNotification, object: nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.isReachable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopMonitoring() {
|
||||||
|
monitor.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSNetworkMonitorManager {
|
||||||
|
///网络发生变化
|
||||||
|
static let networkStatusDidChangeNotification = Notification.Name(rawValue: "XSNetworkMonitorManager.networkStatusDidChangeNotification")
|
||||||
|
}
|
||||||
|
|
||||||
42
XSeri/Base/Networking/Base/XSNetworkPlugin.swift
Normal file
42
XSeri/Base/Networking/Base/XSNetworkPlugin.swift
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
//
|
||||||
|
// XSNetworkPlugin.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Moya
|
||||||
|
|
||||||
|
/// 网络请求加载指示器插件
|
||||||
|
/// 用于处理请求过程中的 HUD 显示与隐藏,实现 UI 与网络逻辑解耦
|
||||||
|
struct XSNetworkHUDPlugin: PluginType {
|
||||||
|
|
||||||
|
func willSend(_ request: RequestType, target: TargetType) {
|
||||||
|
guard let target = target as? XSNetwork.Target,
|
||||||
|
case let .request(parameters) = target else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果配置了需要 loading,则在主线程显示 HUD
|
||||||
|
if parameters.isLoding {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
XSHud.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
|
||||||
|
guard let target = target as? XSNetwork.Target,
|
||||||
|
case let .request(parameters) = target else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无论请求成功或失败,只要配置了 loading,均在结束时隐藏 HUD
|
||||||
|
if parameters.isLoding {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
XSHud.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
XSeri/Base/Networking/Base/XSNetworkTarget.swift
Normal file
89
XSeri/Base/Networking/Base/XSNetworkTarget.swift
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
//
|
||||||
|
// XSNetworkTarget.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Moya
|
||||||
|
import SmartCodable
|
||||||
|
import AdSupport
|
||||||
|
import YYCategories
|
||||||
|
|
||||||
|
extension XSNetwork {
|
||||||
|
enum Target {
|
||||||
|
case request(parameters: XSNetwork.Parameters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSNetwork.Target: TargetType {
|
||||||
|
var baseURL: URL {
|
||||||
|
return .init(string: XSBaseURL)!
|
||||||
|
}
|
||||||
|
|
||||||
|
var path: String {
|
||||||
|
switch self {
|
||||||
|
case .request(let parameters):
|
||||||
|
return XSURLPathPrefix + parameters.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var method: Moya.Method {
|
||||||
|
switch self {
|
||||||
|
case .request(let parameters):
|
||||||
|
return parameters.method
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var task: Moya.Task {
|
||||||
|
switch self {
|
||||||
|
case .request(let parameters):
|
||||||
|
let params = parameters.parameters ?? [:]
|
||||||
|
return .requestParameters(parameters: params, encoding: getEncoding())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var headers: [String : String]? {
|
||||||
|
let userToken = XSLoginManager.manager.token?.token ?? ""
|
||||||
|
let dic: [String : String] = [
|
||||||
|
"system-version" : kXSSystemVersion,
|
||||||
|
"lang-key" : "en", // 当前语言,后续可根据应用语言环境动态获取
|
||||||
|
"time-zone" : String.timeZone(), // 时区
|
||||||
|
"app-version" : kXSVersion,
|
||||||
|
"device-id" : XSDeviceId.shared.id, // 设备id
|
||||||
|
"brand" : "apple", // 品牌
|
||||||
|
"app-name" : kXSBundleName,
|
||||||
|
"system-type" : "ios",
|
||||||
|
"idfa" : ASIdentifierManager.shared().advertisingIdentifier.uuidString,
|
||||||
|
"model" : UIDevice.current.machineModelName ?? "",
|
||||||
|
"authorization" : userToken,
|
||||||
|
"device-gaid" : UIDevice.current.identifierForVendor?.uuidString ?? "",
|
||||||
|
"product-prefix" : "XSeri"
|
||||||
|
]
|
||||||
|
return dic
|
||||||
|
}
|
||||||
|
|
||||||
|
var sampleData: Data {
|
||||||
|
return "".data(using: .utf8)!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension XSNetwork.Target {
|
||||||
|
func getEncoding() -> ParameterEncoding {
|
||||||
|
switch self.method {
|
||||||
|
case .get, .delete:
|
||||||
|
return URLEncoding.default
|
||||||
|
default:
|
||||||
|
return JSONEncoding.default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
static func timeZone() -> String {
|
||||||
|
let timeZone = NSTimeZone.local as NSTimeZone
|
||||||
|
let seconds = timeZone.secondsFromGMT / 3600
|
||||||
|
let sign = seconds >= 0 ? "+" : "-"
|
||||||
|
return String(format: "GMT%@%02d:00", sign, abs(seconds))
|
||||||
|
}
|
||||||
|
}
|
||||||
25
XSeri/Base/Networking/Base/XSURLPath.swift
Normal file
25
XSeri/Base/Networking/Base/XSURLPath.swift
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// XSURLPath.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
let XSBaseURL = "https://api-breeltv.breeltv.com"
|
||||||
|
let XSURLPathPrefix = "/reel"
|
||||||
|
let XSWebBaseURL = "https://www.breeltv.com"
|
||||||
|
let XSCampaignWebURL = "https://campaign.breeltv.com"
|
||||||
|
|
||||||
|
///用户协议
|
||||||
|
let kXSUserAgreementWebUrl = XSWebBaseURL + "/user_policy"
|
||||||
|
///隐私协议
|
||||||
|
let kXSPrivacyPolicyWebUrl = XSWebBaseURL + "/private"
|
||||||
|
|
||||||
|
///反馈首页
|
||||||
|
let kXSFeedBackHomeWebUrl = XSCampaignWebURL + "/pages/leave/index"
|
||||||
|
///反馈列表
|
||||||
|
let kXSFeedBackListWebUrl = XSCampaignWebURL + "/pages/leave/list"
|
||||||
|
///反馈详情
|
||||||
|
let kXSFeedBackDetailWebUrl = XSCampaignWebURL + "/pages/leave/detail"
|
||||||
|
///注销账号
|
||||||
|
let kXSLogoutWebUrl = XSCampaignWebURL + "/pages/setting/logout"
|
||||||
56
XSeri/Base/View/XSButton.swift
Normal file
56
XSeri/Base/View/XSButton.swift
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
//
|
||||||
|
// XSButton.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class XSButton: UIButton {
|
||||||
|
|
||||||
|
override class var layerClass: AnyClass {
|
||||||
|
return CAGradientLayer.self
|
||||||
|
}
|
||||||
|
|
||||||
|
var xs_gradientLayer: CAGradientLayer {
|
||||||
|
return self.layer as! CAGradientLayer
|
||||||
|
}
|
||||||
|
|
||||||
|
var xs_colors: [CGColor]? {
|
||||||
|
get {
|
||||||
|
return xs_gradientLayer.colors as? [CGColor]
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
xs_gradientLayer.colors = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var xs_startPoint: CGPoint {
|
||||||
|
get {
|
||||||
|
return xs_gradientLayer.startPoint
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
xs_gradientLayer.startPoint = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var xs_endPoint: CGPoint {
|
||||||
|
get {
|
||||||
|
return xs_gradientLayer.endPoint
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
xs_gradientLayer.endPoint = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var xs_locations: [NSNumber]? {
|
||||||
|
get {
|
||||||
|
return xs_gradientLayer.locations
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
xs_gradientLayer.locations = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
23
XSeri/Base/View/XSCollectionView.swift
Normal file
23
XSeri/Base/View/XSCollectionView.swift
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// XSCollectionView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class XSCollectionView: UICollectionView {
|
||||||
|
|
||||||
|
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
|
||||||
|
super.init(frame: frame, collectionViewLayout: layout)
|
||||||
|
self.backgroundColor = .clear
|
||||||
|
self.contentInsetAdjustmentBehavior = .never
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
20
XSeri/Base/View/XSCustomTabBar.swift
Normal file
20
XSeri/Base/View/XSCustomTabBar.swift
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// XSCustomTabBar.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/01/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import ESTabBarController_swift
|
||||||
|
|
||||||
|
final class XSCustomTabBar: ESTabBar {
|
||||||
|
|
||||||
|
var contentHeight: CGFloat = 60
|
||||||
|
|
||||||
|
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
||||||
|
var result = super.sizeThatFits(size)
|
||||||
|
result.height = contentHeight + XSScreen.safeBottom
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
98
XSeri/Base/View/XSImageView.swift
Normal file
98
XSeri/Base/View/XSImageView.swift
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
//
|
||||||
|
// XSImageView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
class XSImageView: UIImageView {
|
||||||
|
|
||||||
|
var placeholderColor = UIColor.black
|
||||||
|
var placeholderImage = UIImage(named: "placeholder_image")
|
||||||
|
|
||||||
|
private lazy var placeholderImageView: UIImageView = {
|
||||||
|
let imageView = UIImageView(image: placeholderImage)
|
||||||
|
imageView.isHidden = true
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
xs_init()
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(image: UIImage?) {
|
||||||
|
super.init(image: image)
|
||||||
|
xs_init()
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(image: UIImage?, highlightedImage: UIImage?) {
|
||||||
|
super.init(image: image, highlightedImage: highlightedImage)
|
||||||
|
xs_init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
xs_init()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func awakeFromNib() {
|
||||||
|
super.awakeFromNib()
|
||||||
|
xs_init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_init() {
|
||||||
|
self.contentMode = .scaleAspectFill
|
||||||
|
self.layer.masksToBounds = true
|
||||||
|
if image == nil {
|
||||||
|
self.backgroundColor = self.placeholderColor
|
||||||
|
placeholderImageView.isHidden = false
|
||||||
|
}
|
||||||
|
addSubview(placeholderImageView)
|
||||||
|
}
|
||||||
|
|
||||||
|
override var image: UIImage? {
|
||||||
|
didSet {
|
||||||
|
if self.backgroundColor == nil && image == nil {
|
||||||
|
self.backgroundColor = self.placeholderColor
|
||||||
|
} else if image != nil {
|
||||||
|
if self.backgroundColor == self.placeholderColor {
|
||||||
|
self.backgroundColor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if image == nil {
|
||||||
|
placeholderImageView.isHidden = false
|
||||||
|
} else {
|
||||||
|
placeholderImageView.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
placeholderImageView.frame = .init(x: 0, y: 0, width: self.bounds.width, height: self.bounds.height)
|
||||||
|
placeholderImageView.center = .init(x: self.bounds.width / 2, y: self.bounds.height / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIImageView {
|
||||||
|
func xs_setImage(_ url: String?, placeholder: UIImage? = nil, completer: ((_ image: UIImage?, _ url: URL?) -> Void)? = nil) {
|
||||||
|
|
||||||
|
self.kf.setImage(with: URL(string: url ?? ""), placeholder: placeholder, options: nil) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let value):
|
||||||
|
completer?(value.image, value.source.url)
|
||||||
|
default :
|
||||||
|
completer?(nil, nil)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
XSeri/Base/View/XSLabel.swift
Normal file
56
XSeri/Base/View/XSLabel.swift
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
//
|
||||||
|
// XSLabel.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class XSLabel: UILabel {
|
||||||
|
|
||||||
|
var textColors: [CGColor]?
|
||||||
|
var textStartPoint: CGPoint?
|
||||||
|
var textEndPoint: CGPoint?
|
||||||
|
|
||||||
|
var colorImage: UIImage?
|
||||||
|
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
let size = self.bounds.size
|
||||||
|
if let text = self.text, text.count > 0, let colors = self.textColors, let startPoint = self.textStartPoint, let endPoine = self.textEndPoint {
|
||||||
|
self.textColor = UIColor(patternImage: UIImage.xs_getGradientImage(size: size, colors: colors, startPoint: startPoint, endPoint: endPoine))
|
||||||
|
} else if let image = self.colorImage {
|
||||||
|
self.textColor = UIColor(patternImage: image.xs_resized(to: size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIImage {
|
||||||
|
|
||||||
|
static func xs_getGradientImage(size: CGSize, colors: [CGColor], startPoint: CGPoint, endPoint: CGPoint) -> UIImage {
|
||||||
|
|
||||||
|
UIGraphicsBeginImageContextWithOptions(size, false, UIScreen.main.scale)
|
||||||
|
guard let context = UIGraphicsGetCurrentContext() else{return UIImage()}
|
||||||
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||||
|
///设置渐变颜色
|
||||||
|
let gradientRef = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: nil)!
|
||||||
|
let startPoint = CGPoint(x: size.width * startPoint.x, y: size.height * startPoint.y)
|
||||||
|
let endPoint = CGPoint(x: size.width * endPoint.x, y: size.height * endPoint.y)
|
||||||
|
context.drawLinearGradient(gradientRef, start: startPoint, end: endPoint, options: CGGradientDrawingOptions(arrayLiteral: .drawsBeforeStartLocation,.drawsAfterEndLocation))
|
||||||
|
let gradientImage = UIGraphicsGetImageFromCurrentImageContext()
|
||||||
|
UIGraphicsEndImageContext()
|
||||||
|
return gradientImage ?? UIImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_resized(to size: CGSize) -> UIImage {
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
return renderer.image { _ in
|
||||||
|
self.draw(in: CGRect(origin: .zero, size: size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
76
XSeri/Base/View/XSPanModalContentView.swift
Normal file
76
XSeri/Base/View/XSPanModalContentView.swift
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
//
|
||||||
|
// XSPanModalContentView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import HWPanModal
|
||||||
|
|
||||||
|
class XSPanModalContentView: HWPanModalContentView {
|
||||||
|
|
||||||
|
var contentHeight = XSScreen.height * (2 / 3)
|
||||||
|
|
||||||
|
var mainScrollView: UIScrollView?
|
||||||
|
|
||||||
|
///更新UI contentSize发生变化时调用
|
||||||
|
func setNeedsLayoutUpdate() {
|
||||||
|
self.panModalSetNeedsLayoutUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: HWPanModalPresentable
|
||||||
|
override func panScrollable() -> UIScrollView? {
|
||||||
|
return mainScrollView
|
||||||
|
}
|
||||||
|
|
||||||
|
override func longFormHeight() -> PanModalHeight {
|
||||||
|
return PanModalHeightMake(.content, contentHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func showDragIndicator() -> Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override func backgroundConfig() -> HWBackgroundConfig {
|
||||||
|
let config = HWBackgroundConfig()
|
||||||
|
config.backgroundAlpha = 0.6
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
override func allowsTapBackgroundToDismiss() -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func allowsDragToDismiss() -> Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override func allowsPullDownWhenShortState() -> Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override func showsScrollableVerticalScrollIndicator() -> Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override func springDamping() -> CGFloat {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
override func cornerRadius() -> CGFloat {
|
||||||
|
return 18
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
244
XSeri/Base/View/XSProgressView.swift
Normal file
244
XSeri/Base/View/XSProgressView.swift
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
//
|
||||||
|
// XSProgressView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import YYCategories
|
||||||
|
import YYText
|
||||||
|
|
||||||
|
class XSProgressView: UIView {
|
||||||
|
|
||||||
|
///滑动开始
|
||||||
|
var panStart: (() -> Void)?
|
||||||
|
|
||||||
|
///滑动中
|
||||||
|
var panChange: ((_ progress: CGFloat) -> Void)?
|
||||||
|
|
||||||
|
///滑动完成回调
|
||||||
|
var panFinish: ((_ progress: CGFloat) -> Void)?
|
||||||
|
|
||||||
|
var progress: CGFloat = 0 {
|
||||||
|
didSet {
|
||||||
|
if !isPaning {
|
||||||
|
setNeedsDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///用来记录滑动时的当前进度
|
||||||
|
private var tempProgress: CGFloat = 0
|
||||||
|
|
||||||
|
///滑动进度
|
||||||
|
private var panProgress: CGFloat = 0
|
||||||
|
|
||||||
|
var progressColor: UIColor = .FFDAA_4.withAlphaComponent(0.39) {
|
||||||
|
didSet {
|
||||||
|
setNeedsDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var currentProgress: UIColor = .FFDAA_4 {
|
||||||
|
didSet {
|
||||||
|
setNeedsDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lineWidth: CGFloat = 2 {
|
||||||
|
didSet {
|
||||||
|
setNeedsDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///加载中状态
|
||||||
|
var isLoading = false {
|
||||||
|
didSet {
|
||||||
|
if isLoading {
|
||||||
|
if gradientTimer == nil {
|
||||||
|
gradientTimer = Timer.scheduledTimer(timeInterval: 0.05, target: YYTextWeakProxy(target: self), selector: #selector(handleGradientTimer), userInfo: nil, repeats: true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
gradientTimer?.invalidate()
|
||||||
|
gradientTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var thumbImage: UIImage? {
|
||||||
|
didSet {
|
||||||
|
setNeedsDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var insets: UIEdgeInsets = .init(top: 0, left: 15, bottom: 0, right: 15) {
|
||||||
|
didSet {
|
||||||
|
self.invalidateIntrinsicContentSize()
|
||||||
|
setNeedsDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) lazy var panGesture: UIPanGestureRecognizer = {
|
||||||
|
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(sender:)))
|
||||||
|
return pan
|
||||||
|
}()
|
||||||
|
|
||||||
|
private(set) lazy var tagGesture: UITapGestureRecognizer = {
|
||||||
|
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(sender:)))
|
||||||
|
return tap
|
||||||
|
}()
|
||||||
|
|
||||||
|
///是否在滑动中
|
||||||
|
private var isPaning: Bool = false
|
||||||
|
|
||||||
|
private var gradientTimer: Timer?
|
||||||
|
|
||||||
|
private var gradientValue: CGFloat = 0
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
return .init(width: XSScreen.width, height: lineWidth + insets.top + insets.bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
self.backgroundColor = .clear
|
||||||
|
|
||||||
|
self.addGestureRecognizer(panGesture)
|
||||||
|
self.addGestureRecognizer(tagGesture)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
setNeedsDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleGradientTimer() {
|
||||||
|
gradientValue += 0.1
|
||||||
|
if gradientValue > 1 {
|
||||||
|
gradientValue = 0
|
||||||
|
}
|
||||||
|
setNeedsDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draw(_ rect: CGRect) {
|
||||||
|
super.draw(rect)
|
||||||
|
guard let context = UIGraphicsGetCurrentContext() else { return }
|
||||||
|
let width = rect.width
|
||||||
|
let thumbImageSize = self.thumbImage?.size ?? .zero
|
||||||
|
|
||||||
|
let progressX = insets.left + thumbImageSize.width / 2
|
||||||
|
let progressY = insets.top
|
||||||
|
let progressWidth = width - insets.left - insets.right - thumbImageSize.width
|
||||||
|
|
||||||
|
if isLoading, !isPaning {
|
||||||
|
// 定义颜色空间
|
||||||
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||||
|
let colors: [CGColor] = [
|
||||||
|
UIColor.clear.cgColor,
|
||||||
|
UIColor.white.cgColor,
|
||||||
|
UIColor.clear.cgColor
|
||||||
|
]
|
||||||
|
let locations: [CGFloat] = [0.0, gradientValue, 1.0]
|
||||||
|
|
||||||
|
guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: locations) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let gradientRect = CGRect(x: progressX,
|
||||||
|
y: progressY,
|
||||||
|
width: progressWidth,
|
||||||
|
height: lineWidth)
|
||||||
|
|
||||||
|
// 定义渐变的起点和终点
|
||||||
|
let startPoint = CGPoint(x: rect.minX, y: rect.minY)
|
||||||
|
let endPoint = CGPoint(x: rect.maxX, y: rect.maxY)
|
||||||
|
|
||||||
|
// 裁剪到渐变区域
|
||||||
|
context.saveGState()
|
||||||
|
context.clip(to: gradientRect)
|
||||||
|
|
||||||
|
// 绘制渐变
|
||||||
|
context.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: [])
|
||||||
|
} else {
|
||||||
|
var progress = self.progress
|
||||||
|
if self.isPaning {
|
||||||
|
progress = self.panProgress
|
||||||
|
}
|
||||||
|
if progress < 0 {
|
||||||
|
progress = 0
|
||||||
|
}
|
||||||
|
if progress > 1 {
|
||||||
|
progress = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
///绘制进度
|
||||||
|
let progressPath = UIBezierPath(roundedRect: CGRect(x: progressX, y: progressY, width: progressWidth, height: lineWidth), cornerRadius: lineWidth / 2)
|
||||||
|
context.addPath(progressPath.cgPath)
|
||||||
|
context.setFillColor(progressColor.cgColor)
|
||||||
|
context.fillPath()
|
||||||
|
|
||||||
|
///绘制当前进度
|
||||||
|
let currentPath = UIBezierPath(roundedRect: CGRect(x: progressX, y: progressY, width: progressWidth * progress, height: lineWidth), cornerRadius: lineWidth / 2)
|
||||||
|
context.addPath(currentPath.cgPath)
|
||||||
|
context.setFillColor(currentProgress.cgColor)
|
||||||
|
context.fillPath()
|
||||||
|
|
||||||
|
if let thumbImage = thumbImage {
|
||||||
|
let size = thumbImage.size
|
||||||
|
let frame = CGRect(x: progressWidth * progress - size.width / 2 + progressX, y: progressY - size.width / 2 + lineWidth / 2, width: size.width, height: size.height)
|
||||||
|
|
||||||
|
thumbImage.draw(in: frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSProgressView {
|
||||||
|
|
||||||
|
@objc func handlePanGesture(sender: UIPanGestureRecognizer) {
|
||||||
|
|
||||||
|
switch sender.state {
|
||||||
|
case .began:
|
||||||
|
self.isPaning = true
|
||||||
|
self.tempProgress = self.progress
|
||||||
|
sender.setTranslation(CGPoint(x: 0, y: 0), in: self)
|
||||||
|
self.panStart?()
|
||||||
|
|
||||||
|
case .changed:
|
||||||
|
let point = sender.translation(in: self)
|
||||||
|
let offsetX = point.x / (self.width - self.insets.left - self.insets.right)
|
||||||
|
self.panProgress = self.tempProgress + offsetX
|
||||||
|
if self.panProgress < 0 {
|
||||||
|
self.panProgress = 0
|
||||||
|
}
|
||||||
|
if self.panProgress > 1 {
|
||||||
|
self.panProgress = 1
|
||||||
|
}
|
||||||
|
self.panChange?(self.panProgress)
|
||||||
|
setNeedsDisplay()
|
||||||
|
|
||||||
|
default:
|
||||||
|
self.isPaning = false
|
||||||
|
self.panFinish?(self.panProgress)
|
||||||
|
|
||||||
|
self.panProgress = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func handleTapGesture(sender: UITapGestureRecognizer) {
|
||||||
|
let point = sender.location(in: self)
|
||||||
|
let offsetX = (point.x - self.insets.left) / (self.width - self.insets.left - self.insets.right)
|
||||||
|
self.panFinish?(offsetX)
|
||||||
|
}
|
||||||
|
}
|
||||||
83
XSeri/Base/View/XSTabBarItemContentView.swift
Normal file
83
XSeri/Base/View/XSTabBarItemContentView.swift
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import UIKit
|
||||||
|
import ESTabBarController_swift
|
||||||
|
|
||||||
|
class XSTabBarItemContentView: ESTabBarItemContentView {
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
// 设置未选中状态的颜色
|
||||||
|
textColor = XSConfig.Color.tabBarInactive
|
||||||
|
iconColor = XSConfig.Color.tabBarInactive
|
||||||
|
|
||||||
|
// 设置选中状态的颜色
|
||||||
|
highlightTextColor = XSConfig.Color.tabBarActive
|
||||||
|
highlightIconColor = XSConfig.Color.tabBarActive
|
||||||
|
|
||||||
|
// 允许标题不压缩,解决省略号问题
|
||||||
|
titleLabel.numberOfLines = 1
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
|
||||||
|
// 禁止 ImageView 拉伸
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
}
|
||||||
|
|
||||||
|
public required init?(coder aDecoder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func updateLayout() {
|
||||||
|
super.updateLayout()
|
||||||
|
|
||||||
|
// 1. 处理图标大小 (根据 Figma,选中 36pt,未选中 24pt)
|
||||||
|
let iconSize: CGFloat = selected ? 36.0 : 24.0
|
||||||
|
// 调整纵向位置,确保视觉居中
|
||||||
|
let iconY: CGFloat = selected ? 6.0 : 10.0
|
||||||
|
imageView.frame = CGRect(x: (bounds.size.width - iconSize) / 2.0,
|
||||||
|
y: iconY,
|
||||||
|
width: iconSize,
|
||||||
|
height: iconSize)
|
||||||
|
|
||||||
|
// 2. 处理文字布局,防止省略号
|
||||||
|
titleLabel.font = selected ? XSConfig.Font.tabBarActive : XSConfig.Font.tabBarInactive
|
||||||
|
titleLabel.sizeToFit()
|
||||||
|
// 给予足够的宽度以防被截断
|
||||||
|
let labelWidth = bounds.size.width + 20
|
||||||
|
titleLabel.frame = CGRect(x: (bounds.size.width - labelWidth) / 2.0,
|
||||||
|
y: imageView.frame.maxY + 2.0,
|
||||||
|
width: labelWidth,
|
||||||
|
height: 14.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func selectAnimation(animated: Bool, completion: (() -> Void)?) {
|
||||||
|
super.selectAnimation(animated: animated, completion: completion)
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
// 执行平滑放大动画
|
||||||
|
UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut], animations: {
|
||||||
|
self.updateLayout()
|
||||||
|
}, completion: { _ in
|
||||||
|
completion?()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
self.updateLayout()
|
||||||
|
completion?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func deselectAnimation(animated: Bool, completion: (() -> Void)?) {
|
||||||
|
super.deselectAnimation(animated: animated, completion: completion)
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
// 执行平滑缩小还原动画
|
||||||
|
UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseIn], animations: {
|
||||||
|
self.updateLayout()
|
||||||
|
}, completion: { _ in
|
||||||
|
completion?()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
self.updateLayout()
|
||||||
|
completion?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
XSeri/Base/View/XSTableView.swift
Normal file
49
XSeri/Base/View/XSTableView.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// XSTableView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class XSTableView: UITableView {
|
||||||
|
|
||||||
|
var insetGroupedMargins: CGFloat = 15
|
||||||
|
|
||||||
|
override init(frame: CGRect, style: UITableView.Style) {
|
||||||
|
super.init(frame: frame, style: style)
|
||||||
|
separatorColor = .white.withAlphaComponent(0.1)
|
||||||
|
separatorInset = .init(top: 0, left: 16, bottom: 0, right: 16)
|
||||||
|
self.backgroundColor = .clear
|
||||||
|
self.contentInsetAdjustmentBehavior = .never
|
||||||
|
|
||||||
|
if style == .insetGrouped {
|
||||||
|
sectionFooterHeight = 14
|
||||||
|
sectionHeaderHeight = 0.1
|
||||||
|
} else if style == .plain {
|
||||||
|
if #available(iOS 15.0, *) {
|
||||||
|
sectionHeaderTopPadding = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override var layoutMargins: UIEdgeInsets {
|
||||||
|
set {
|
||||||
|
super.layoutMargins = newValue
|
||||||
|
}
|
||||||
|
get {
|
||||||
|
var margins = super.layoutMargins
|
||||||
|
if self.style == .insetGrouped {
|
||||||
|
margins.left = self.safeAreaInsets.left + insetGroupedMargins
|
||||||
|
margins.right = self.safeAreaInsets.right + insetGroupedMargins
|
||||||
|
}
|
||||||
|
return margins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
49
XSeri/Base/View/XSTableViewCell.swift
Normal file
49
XSeri/Base/View/XSTableViewCell.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// XSTableViewCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class XSTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
|
private(set) lazy var xs_indicatorImageView: UIImageView = {
|
||||||
|
let imageView = UIImageView(image: UIImage(named: "arrow_right_icon_01"))
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
|
||||||
|
self.layer.rasterizationScale = UIScreen.main.scale
|
||||||
|
self.layer.shouldRasterize = true
|
||||||
|
self.selectionStyle = .none
|
||||||
|
self.backgroundColor = .clear
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func awakeFromNib() {
|
||||||
|
super.awakeFromNib()
|
||||||
|
// Initialization code
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setSelected(_ selected: Bool, animated: Bool) {
|
||||||
|
super.setSelected(selected, animated: animated)
|
||||||
|
|
||||||
|
// Configure the view for the selected state
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UITableViewCell {
|
||||||
|
|
||||||
|
var br_tableView: UITableView? {
|
||||||
|
return self.value(forKey: "_tableView") as? UITableView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
56
XSeri/Base/View/XSView.swift
Normal file
56
XSeri/Base/View/XSView.swift
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
//
|
||||||
|
// XSView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class XSView: UIView {
|
||||||
|
|
||||||
|
override class var layerClass: AnyClass {
|
||||||
|
return CAGradientLayer.self
|
||||||
|
}
|
||||||
|
|
||||||
|
var xs_gradientLayer: CAGradientLayer {
|
||||||
|
return self.layer as! CAGradientLayer
|
||||||
|
}
|
||||||
|
|
||||||
|
var xs_colors: [CGColor]? {
|
||||||
|
get {
|
||||||
|
return xs_gradientLayer.colors as? [CGColor]
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
xs_gradientLayer.colors = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var xs_startPoint: CGPoint {
|
||||||
|
get {
|
||||||
|
return xs_gradientLayer.startPoint
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
xs_gradientLayer.startPoint = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var xs_endPoint: CGPoint {
|
||||||
|
get {
|
||||||
|
return xs_gradientLayer.endPoint
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
xs_gradientLayer.endPoint = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var xs_locations: [NSNumber]? {
|
||||||
|
get {
|
||||||
|
return xs_gradientLayer.locations
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
xs_gradientLayer.locations = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
78
XSeri/Base/WebView/XSAppWebViewController.swift
Normal file
78
XSeri/Base/WebView/XSAppWebViewController.swift
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
//
|
||||||
|
// XSAppWebViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/28.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class XSAppWebViewController: XSBaseWebViewController {
|
||||||
|
|
||||||
|
var id: String?
|
||||||
|
|
||||||
|
private var receiveDataCount = 0
|
||||||
|
|
||||||
|
var theme: String? = "theme_3"
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
self.autoTitle = false
|
||||||
|
|
||||||
|
if webUrl == kXSFeedBackListWebUrl {
|
||||||
|
self.title = "feedback_history".localized
|
||||||
|
} else if webUrl == kXSFeedBackHomeWebUrl {
|
||||||
|
self.title = "feedback".localized
|
||||||
|
} else if webUrl == kXSFeedBackDetailWebUrl {
|
||||||
|
self.title = "feedback_detail".localized
|
||||||
|
} else if webUrl == kXSLogoutWebUrl {
|
||||||
|
self.title = "account_deletion".localized
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override func xs_webViewDidFinishLoad(_ webView: XSWebView) {
|
||||||
|
super.xs_webViewDidFinishLoad(webView)
|
||||||
|
receiveDataCount = 0
|
||||||
|
receiveDataFromNative()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSAppWebViewController {
|
||||||
|
|
||||||
|
func receiveDataFromNative() {
|
||||||
|
receiveDataCount += 1
|
||||||
|
if receiveDataCount > 10 { return }
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
var dic = [
|
||||||
|
"token" : XSLoginManager.manager.token?.token ?? "",
|
||||||
|
"time_zone" : String.timeZone(),
|
||||||
|
"lang" : "en",
|
||||||
|
"type" : "ios",
|
||||||
|
"device-id" : XSDeviceId.shared.id
|
||||||
|
]
|
||||||
|
|
||||||
|
if let theme = theme {
|
||||||
|
dic["theme"] = theme
|
||||||
|
}
|
||||||
|
|
||||||
|
if let id = id {
|
||||||
|
dic["id"] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
if let json = dic.toJsonString() {
|
||||||
|
let js = "receiveDataFromNative(\(json))"
|
||||||
|
self.webView.evaluateJavaScript(js) { [weak self] _, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if error != nil {
|
||||||
|
self.receiveDataFromNative()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
111
XSeri/Base/WebView/XSBaseWebViewController+Script.swift
Normal file
111
XSeri/Base/WebView/XSBaseWebViewController+Script.swift
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
//
|
||||||
|
// XSBaseWebViewController+Script.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/28.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
import ZLPhotoBrowser
|
||||||
|
import SmartCodable
|
||||||
|
|
||||||
|
|
||||||
|
///APP交互
|
||||||
|
let kXSWebMessageAPP = "js2app"
|
||||||
|
///打开反馈列表
|
||||||
|
let kXSWebMessageOpenFeedbackList = "openFeedbackList"
|
||||||
|
///打开反馈详情
|
||||||
|
let kXSWebMessageOpenFeedbackDetail = "openFeedbackDetail"
|
||||||
|
///打开相册
|
||||||
|
let kXSWebMessageOpenPhotoPicker = "openPhotoPicker"
|
||||||
|
///删除账号成功
|
||||||
|
let kXSWebMessageAccountDeletionFinish = "accountLogout"
|
||||||
|
|
||||||
|
extension XSBaseWebViewController {
|
||||||
|
|
||||||
|
func xs_webViewUserContentController(didReceive message: WKScriptMessage) {
|
||||||
|
let name = message.name
|
||||||
|
let body = message.body
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case kXSWebMessageOpenFeedbackList:
|
||||||
|
let vc = XSAppWebViewController()
|
||||||
|
vc.webUrl = kXSFeedBackListWebUrl
|
||||||
|
self.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
|
||||||
|
case kXSWebMessageOpenFeedbackDetail:
|
||||||
|
guard let body = body as? [String : Any] else { return }
|
||||||
|
guard let id = body["id"] as? Int else { return }
|
||||||
|
|
||||||
|
let vc = XSAppWebViewController()
|
||||||
|
vc.id = "\(id)"
|
||||||
|
vc.webUrl = kXSFeedBackDetailWebUrl
|
||||||
|
self.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
|
||||||
|
case kXSWebMessageOpenPhotoPicker:
|
||||||
|
openPhotoPicker()
|
||||||
|
|
||||||
|
case kXSWebMessageAPP:
|
||||||
|
guard let body = message.body as? [String : Any] else { return }
|
||||||
|
guard let model = XSWebMessageModel.deserialize(from: body) else { return }
|
||||||
|
let type = model.type
|
||||||
|
let data = model.data
|
||||||
|
|
||||||
|
if type == "login" {
|
||||||
|
// let view = FALoginView()
|
||||||
|
// view.present(in: nil)
|
||||||
|
|
||||||
|
} else if type == "open_notify" {
|
||||||
|
// openNotify()
|
||||||
|
|
||||||
|
} else if type == "watch_video" {
|
||||||
|
let vc = XSShortDetailViewController()
|
||||||
|
vc.shortId = data?.short_play_id
|
||||||
|
vc.activityId = data?.activity_id
|
||||||
|
self.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
} else {
|
||||||
|
|
||||||
|
guard let urlStr = data?.link else { return }
|
||||||
|
guard let url = URL(string: urlStr) else { return }
|
||||||
|
if UIApplication.shared.canOpenURL(url) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case kXSWebMessageAccountDeletionFinish:
|
||||||
|
self.navigationController?.popToRootViewController(animated: true)
|
||||||
|
NotificationCenter.default.post(name: XSLoginManager.loginStateDidChangeNotification, object: nil)
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
///打开相册
|
||||||
|
private func openPhotoPicker() {
|
||||||
|
|
||||||
|
ZLPhotoConfiguration.default().allowSelectOriginal = false
|
||||||
|
ZLPhotoConfiguration.default().maxSelectCount = 1
|
||||||
|
ZLPhotoConfiguration.default().allowEditImage = false
|
||||||
|
ZLPhotoConfiguration.default().allowSelectVideo = false
|
||||||
|
ZLPhotoConfiguration.default().allowSelectGif = false
|
||||||
|
ZLPhotoConfiguration.default().allowTakePhotoInLibrary = false
|
||||||
|
|
||||||
|
let picker = ZLPhotoPicker()
|
||||||
|
picker.selectImageBlock = { [weak self] (results, _) in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard let image = results.first?.image else { return }
|
||||||
|
guard let imageData = image.jpegData(compressionQuality: 0.8) else { return }
|
||||||
|
let imageDataStr = imageData.base64EncodedString(options: .endLineWithCarriageReturn)
|
||||||
|
|
||||||
|
let js = "uploadConvertImage('\(imageDataStr)')"
|
||||||
|
self.webView.evaluateJavaScript(js)
|
||||||
|
}
|
||||||
|
|
||||||
|
picker.showPhotoLibrary(sender: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
113
XSeri/Base/WebView/XSBaseWebViewController.swift
Normal file
113
XSeri/Base/WebView/XSBaseWebViewController.swift
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
//
|
||||||
|
// XSBaseWebViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSBaseWebViewController: XSViewController {
|
||||||
|
|
||||||
|
var webUrl: String?
|
||||||
|
|
||||||
|
///自动设置标题
|
||||||
|
var autoTitle = true
|
||||||
|
|
||||||
|
var needAutoRefresh = true
|
||||||
|
|
||||||
|
private(set) lazy var webView: XSWebView = {
|
||||||
|
let controller = WKUserContentController()
|
||||||
|
|
||||||
|
let config = WKWebViewConfiguration()
|
||||||
|
config.userContentController = controller
|
||||||
|
config.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||||
|
config.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||||
|
let webView = XSWebView(frame: self.view.bounds, configuration: config)
|
||||||
|
webView.delegate = self
|
||||||
|
return webView
|
||||||
|
}()
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
// self.edgesForExtendedLayout = []
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
|
||||||
|
if let url = webUrl {
|
||||||
|
self.load(webUrl: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
self.navigationController?.setNavigationBarHidden(false, animated: true)
|
||||||
|
self.xs_setNavigationStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(webUrl: String) {
|
||||||
|
let str: String = webUrl
|
||||||
|
|
||||||
|
guard let url = URL(string: str) else { return }
|
||||||
|
let request = URLRequest(url: url, timeoutInterval: 30)
|
||||||
|
|
||||||
|
self.webView.load(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reload() {
|
||||||
|
self.webView.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSBaseWebViewController {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
self.view.addSubview(webView)
|
||||||
|
|
||||||
|
self.webView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview().offset(0)
|
||||||
|
make.right.equalToSuperview().offset(0)
|
||||||
|
make.bottom.equalToSuperview().offset(0)
|
||||||
|
make.top.equalTo(self.view.safeAreaLayoutGuide)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: -------------- VPWebViewDelegate --------------
|
||||||
|
extension XSBaseWebViewController: XSWebViewDelegate {
|
||||||
|
|
||||||
|
func xs_webView(_ webView: XSWebView, shouldStartLoadWith navigationAction: WKNavigationAction) -> Bool {
|
||||||
|
self.webView.isHidden = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_webViewDidStartLoad(_ webView: XSWebView) {
|
||||||
|
XSHud.show(containerView: self.view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_webView(webView: XSWebView, didChangeTitle title: String) {
|
||||||
|
if autoTitle {
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_webViewDidFinishLoad(_ webView: XSWebView) {
|
||||||
|
self.webView.isHidden = false
|
||||||
|
XSHud.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_webView(_ webView: XSWebView, didFailLoadWithError error: any Error) {
|
||||||
|
XSHud.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
func xs_userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
self.xs_webViewUserContentController(didReceive: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
22
XSeri/Base/WebView/XSWebMessageModel.swift
Normal file
22
XSeri/Base/WebView/XSWebMessageModel.swift
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// XSWebMessageModel.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/28.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SmartCodable
|
||||||
|
|
||||||
|
struct XSWebMessageModel: SmartCodable {
|
||||||
|
var type: String?
|
||||||
|
|
||||||
|
var data: XSWebMessageData?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct XSWebMessageData: SmartCodable {
|
||||||
|
|
||||||
|
var activity_id: String?
|
||||||
|
var short_play_id: String?
|
||||||
|
var link: String?
|
||||||
|
}
|
||||||
153
XSeri/Base/WebView/XSWebView.swift
Normal file
153
XSeri/Base/WebView/XSWebView.swift
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
//
|
||||||
|
// XSWebView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
import YYText
|
||||||
|
|
||||||
|
//MARK:-------------- VPWebViewDelegate --------------
|
||||||
|
@objc protocol XSWebViewDelegate: NSObjectProtocol {
|
||||||
|
|
||||||
|
@objc optional func xs_webView(_ webView: XSWebView, shouldStartLoadWith navigationAction: WKNavigationAction) -> Bool
|
||||||
|
|
||||||
|
@objc optional func xs_webViewDidStartLoad(_ webView: XSWebView)
|
||||||
|
|
||||||
|
@objc optional func xs_webViewDidFinishLoad(_ webView: XSWebView)
|
||||||
|
|
||||||
|
@objc optional func xs_webView(_ webView: XSWebView, didFailLoadWithError error: Error)
|
||||||
|
|
||||||
|
///进度
|
||||||
|
@objc optional func xs_webView(webView: XSWebView, didChangeProgress progress: CGFloat)
|
||||||
|
///标题
|
||||||
|
@objc optional func xs_webView(webView: XSWebView, didChangeTitle title: String)
|
||||||
|
|
||||||
|
///web交互用
|
||||||
|
@objc optional func xs_userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class XSWebView: WKWebView {
|
||||||
|
weak var delegate: XSWebViewDelegate?
|
||||||
|
|
||||||
|
private(set) var scriptMessageHandlerArray: [String] = [
|
||||||
|
kXSWebMessageAPP,
|
||||||
|
kXSWebMessageOpenFeedbackList,
|
||||||
|
kXSWebMessageOpenFeedbackDetail,
|
||||||
|
kXSWebMessageOpenPhotoPicker,
|
||||||
|
kXSWebMessageAccountDeletionFinish,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
self.removeObserver(self, forKeyPath: "estimatedProgress")
|
||||||
|
self.removeObserver(self, forKeyPath: "title")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect, configuration: WKWebViewConfiguration) {
|
||||||
|
super.init(frame: frame, configuration: configuration)
|
||||||
|
addScriptMessageHandler()
|
||||||
|
_setupInit()
|
||||||
|
}
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
private func _setupInit() {
|
||||||
|
|
||||||
|
self.isOpaque = false
|
||||||
|
self.navigationDelegate = self
|
||||||
|
self.addObserver(self, forKeyPath: "estimatedProgress", options: .new, context: nil)
|
||||||
|
self.addObserver(self, forKeyPath: "title", options: .new, context: nil)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||||
|
if object as? XSWebView == self {
|
||||||
|
if keyPath == "estimatedProgress", let progress = change?[NSKeyValueChangeKey.newKey] as? CGFloat {
|
||||||
|
self.delegate?.xs_webView?(webView: self, didChangeProgress: progress)
|
||||||
|
} else if keyPath == "title", let title = change?[NSKeyValueChangeKey.newKey] as? String {
|
||||||
|
self.delegate?.xs_webView?(webView: self, didChangeTitle: title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(urlStr: String) {
|
||||||
|
guard let url = URL(string: urlStr) else { return }
|
||||||
|
let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 30)
|
||||||
|
self.load(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeScriptMessageHandler() {
|
||||||
|
self.scriptMessageHandlerArray.forEach{
|
||||||
|
configuration.userContentController.removeScriptMessageHandler(forName: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func addScriptMessageHandler() {
|
||||||
|
self.scriptMessageHandlerArray.forEach{
|
||||||
|
configuration.userContentController.add(YYTextWeakProxy(target: self) as! WKScriptMessageHandler, name: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK:-------------- WKNavigationDelegate --------------
|
||||||
|
extension XSWebView: WKNavigationDelegate {
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||||
|
|
||||||
|
decisionHandler(.allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
|
|
||||||
|
if let url = navigationAction.request.url,
|
||||||
|
url.scheme != "http",
|
||||||
|
url.scheme != "https"
|
||||||
|
{
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let result = self.delegate?.xs_webView?(self, shouldStartLoadWith: navigationAction) {
|
||||||
|
if result {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
} else {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||||
|
self.delegate?.xs_webViewDidStartLoad?(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
self.delegate?.xs_webViewDidFinishLoad?(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
|
self.delegate?.xs_webView?(self, didFailLoadWithError: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||||
|
self.delegate?.xs_webView?(self, didFailLoadWithError: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK:-------------- WKScriptMessageHandler --------------
|
||||||
|
extension XSWebView: WKScriptMessageHandler {
|
||||||
|
|
||||||
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
self.delegate?.xs_userContentController?(userContentController, didReceive: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
//
|
||||||
|
// XSDiscoverViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import JXPlayer
|
||||||
|
|
||||||
|
class XSDiscoverViewController: JXPlayerListViewController {
|
||||||
|
|
||||||
|
override var contentSize: CGSize {
|
||||||
|
return .init(width: XSScreen.width, height: XSScreen.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
override var ViewModelClass: JXPlayerListViewModel.Type {
|
||||||
|
return XSDiscoverViewModel.self
|
||||||
|
}
|
||||||
|
|
||||||
|
var xs_viewModel: XSDiscoverViewModel {
|
||||||
|
return self.viewModel as! XSDiscoverViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
self.register(XSDiscoverPlayerCell.self, forCellWithReuseIdentifier: "cell")
|
||||||
|
self.delegate = self
|
||||||
|
self.dataSource = self
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await self.xs_viewModel.requestDataArr(page: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
self.navigationController?.setNavigationBarHidden(true, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
self.viewModel.currentCell?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//MARK: JXPlayerListViewControllerDataSource
|
||||||
|
extension XSDiscoverViewController: JXPlayerListViewControllerDataSource {
|
||||||
|
func jx_playerListViewController(_ viewController: JXPlayerListViewController, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
|
let cell = self.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! XSDiscoverPlayerCell
|
||||||
|
cell.model = xs_viewModel.dataArr[indexPath.row]
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func jx_playerListViewController(_ viewController: JXPlayerListViewController, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
return xs_viewModel.dataArr.count
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: JXPlayerListViewControllerDelegate
|
||||||
|
extension XSDiscoverViewController: JXPlayerListViewControllerDelegate {
|
||||||
|
func jx_playerViewControllerLoadMoreData(playerViewController: JXPlayerListViewController) {
|
||||||
|
Task {
|
||||||
|
await self.xs_viewModel.requestDataArr(page: self.xs_viewModel.page + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
201
XSeri/Class/Discover/View/XSDiscoverControlView.swift
Normal file
201
XSeri/Class/Discover/View/XSDiscoverControlView.swift
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
//
|
||||||
|
// XSDiscoverControlView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import JXPlayer
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSDiscoverControlView: JXPlayerListControlView {
|
||||||
|
|
||||||
|
|
||||||
|
override var model: Any? {
|
||||||
|
didSet {
|
||||||
|
let model = self.model as? XSShortModel
|
||||||
|
let videoInfo = model?.video_info
|
||||||
|
|
||||||
|
epView.currentEp = videoInfo?.episode ?? ""
|
||||||
|
epView.totalEp = "\(model?.episode_total ?? 0)"
|
||||||
|
|
||||||
|
collectButton.setNeedsUpdateConfiguration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var viewModel: JXPlayerListViewModel? {
|
||||||
|
didSet {
|
||||||
|
self.viewModel?.addObserver(self, forKeyPath: "isPlaying", context: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var durationTime: TimeInterval {
|
||||||
|
didSet {
|
||||||
|
updateProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var currentTime: TimeInterval {
|
||||||
|
didSet {
|
||||||
|
updateProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var isCurrent: Bool {
|
||||||
|
didSet {
|
||||||
|
updatePlayIconState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var progressView: XSProgressView = {
|
||||||
|
let view = XSProgressView()
|
||||||
|
view.insets = .init(top: 10, left: 16, bottom: 10, right: 16)
|
||||||
|
view.panFinish = { [weak self] progress in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.viewModel?.seekTo(Float(progress))
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var epView: XSPlayerEpButton = {
|
||||||
|
let view = XSPlayerEpButton()
|
||||||
|
view.addAction(UIAction(handler: { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let vc = XSShortDetailViewController()
|
||||||
|
vc.shortId = (self.model as? XSShortModel)?.short_play_id
|
||||||
|
self.viewController?.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
}), for: .touchUpInside)
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var collectButton: UIButton = {
|
||||||
|
var configuration = UIButton.Configuration.plain()
|
||||||
|
configuration.contentInsets = .zero
|
||||||
|
configuration.imagePadding = 2
|
||||||
|
configuration.imagePlacement = .top
|
||||||
|
configuration.attributedTitle = AttributedString("Save".localized, attributes: AttributeContainer([
|
||||||
|
.font : UIFont.font(ofSize: 12, weight: .bold),
|
||||||
|
.foregroundColor : UIColor.FFDAA_4
|
||||||
|
]))
|
||||||
|
|
||||||
|
let button = UIButton(configuration: configuration, primaryAction: UIAction(handler: { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard let model = self.model as? XSShortModel else { return }
|
||||||
|
let isCollect = !(model.is_collect ?? false)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await XSVideoAPI.requestCollectShort(isCollect: isCollect, shortId: model.short_play_id ?? "0", videoId: model.video_info?.short_play_video_id ?? "0")
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
button.configurationUpdateHandler = { [weak self] button in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let model = self.model as? XSShortModel
|
||||||
|
let videoInfo = model?.video_info
|
||||||
|
|
||||||
|
if model?.is_collect == true {
|
||||||
|
button.configuration?.image = UIImage(named: "collect_icon_01_selected")
|
||||||
|
} else {
|
||||||
|
button.configuration?.image = UIImage(named: "collect_icon_01")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return button
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var playIconImageView = UIImageView()
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(updateShortCollectStateNotification), name: XSVideoAPI.updateShortCollectStateNotification, object: nil)
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||||
|
if keyPath == "isPlaying" {
|
||||||
|
self.updatePlayIconState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateProgress() {
|
||||||
|
if durationTime == 0 || currentTime == 0 {
|
||||||
|
progressView.progress = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
progressView.progress = currentTime / durationTime
|
||||||
|
}
|
||||||
|
|
||||||
|
override func singleTapEvent() {
|
||||||
|
super.singleTapEvent()
|
||||||
|
self.viewModel?.userSwitchPlayAndPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePlayIconState() {
|
||||||
|
let model = self.model as? XSShortModel
|
||||||
|
let videoInfo = model?.video_info
|
||||||
|
|
||||||
|
if videoInfo?.is_lock == true {
|
||||||
|
playIconImageView.isHidden = true
|
||||||
|
} else {
|
||||||
|
playIconImageView.isHidden = false
|
||||||
|
if isCurrent == true, self.viewModel?.isPlaying != true {
|
||||||
|
playIconImageView.image = UIImage(named: "play_icon_01")
|
||||||
|
} else {
|
||||||
|
playIconImageView.image = UIImage(named: "pause_icon_01")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func updateShortCollectStateNotification(sender: Notification) {
|
||||||
|
let model = self.model as? XSShortModel
|
||||||
|
|
||||||
|
guard let userInfo = sender.userInfo else { return }
|
||||||
|
guard let shortPlayId = userInfo["id"] as? String else { return }
|
||||||
|
guard let state = userInfo["state"] as? Bool else { return }
|
||||||
|
guard shortPlayId == model?.short_play_id else { return }
|
||||||
|
model?.is_collect = state
|
||||||
|
self.collectButton.setNeedsUpdateConfiguration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSDiscoverControlView {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
addSubview(progressView)
|
||||||
|
addSubview(epView)
|
||||||
|
addSubview(collectButton)
|
||||||
|
addSubview(playIconImageView)
|
||||||
|
|
||||||
|
progressView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview()
|
||||||
|
make.right.equalToSuperview()
|
||||||
|
make.bottom.equalToSuperview().offset(-(XSScreen.customTabBarHeight))
|
||||||
|
}
|
||||||
|
|
||||||
|
epView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview().offset(16)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.bottom.equalTo(progressView.snp.top).offset(-2)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectButton.snp.makeConstraints { make in
|
||||||
|
make.bottom.equalTo(epView.snp.top).offset(-18)
|
||||||
|
make.right.equalToSuperview().offset(-16)
|
||||||
|
}
|
||||||
|
|
||||||
|
playIconImageView.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
28
XSeri/Class/Discover/View/XSDiscoverPlayerCell.swift
Normal file
28
XSeri/Class/Discover/View/XSDiscoverPlayerCell.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// XSDiscoverPlayerCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import JXPlayer
|
||||||
|
|
||||||
|
class XSDiscoverPlayerCell: JXPlayerListCell {
|
||||||
|
|
||||||
|
|
||||||
|
override var ControlViewClass: JXPlayerListControlView.Type {
|
||||||
|
return XSDiscoverControlView.self
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override var model: Any? {
|
||||||
|
didSet {
|
||||||
|
let model = self.model as? XSShortModel
|
||||||
|
let videoInfo = model?.video_info
|
||||||
|
|
||||||
|
self.player.setPlayUrl(url: videoInfo?.video_url ?? "")
|
||||||
|
self.player.coverImageView?.xs_setImage(model?.image_url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
XSeri/Class/Discover/ViewModel/XSDiscoverViewModel.swift
Normal file
54
XSeri/Class/Discover/ViewModel/XSDiscoverViewModel.swift
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
//
|
||||||
|
// XSDiscoverViewModel.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import JXPlayer
|
||||||
|
|
||||||
|
class XSDiscoverViewModel: JXPlayerListViewModel {
|
||||||
|
|
||||||
|
private(set) var dataArr: [XSShortModel] = []
|
||||||
|
private(set) var page = 1
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func addDataArr(dataArr: [XSShortModel]) {
|
||||||
|
guard dataArr.count > 0 else { return }
|
||||||
|
|
||||||
|
var indexPaths: [IndexPath] = []
|
||||||
|
var startRow = self.dataArr.count
|
||||||
|
|
||||||
|
dataArr.forEach { _ in
|
||||||
|
indexPaths.append(IndexPath(row: startRow, section: 0))
|
||||||
|
startRow += 1
|
||||||
|
}
|
||||||
|
self.dataArr += dataArr
|
||||||
|
|
||||||
|
CATransaction.setCompletionBlock(nil)
|
||||||
|
CATransaction.begin()
|
||||||
|
self.playerListVC?.collectionView.insertItems(at: indexPaths)
|
||||||
|
CATransaction.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSDiscoverViewModel {
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func requestDataArr(page: Int, completer: (() -> Void)? = nil) async {
|
||||||
|
guard let dataArr = await XSHomeAPI.requestDiscoverData(page: page) else { return }
|
||||||
|
self.page = page
|
||||||
|
if page == 1 {
|
||||||
|
self.playerListVC?.clearData()
|
||||||
|
self.dataArr = dataArr
|
||||||
|
self.playerListVC?.reloadData { [weak self] in
|
||||||
|
|
||||||
|
self?.playerListVC?.scrollToItem(indexPath: .init(row: 0, section: 0), animated: false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.addDataArr(dataArr: dataArr)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
133
XSeri/Class/Home/Controller/XSHomeCategoriesViewController.swift
Normal file
133
XSeri/Class/Home/Controller/XSHomeCategoriesViewController.swift
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
//
|
||||||
|
// XSHomeCategoriesViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomeCategoriesViewController: XSHomeChildViewController {
|
||||||
|
|
||||||
|
|
||||||
|
private lazy var categoryArr: [XSCategoryModel] = []
|
||||||
|
|
||||||
|
private lazy var categoryIndex = 0
|
||||||
|
|
||||||
|
private lazy var dataArr: [XSShortModel] = []
|
||||||
|
|
||||||
|
private lazy var page = 1
|
||||||
|
|
||||||
|
private lazy var layout: UICollectionViewFlowLayout = {
|
||||||
|
let width = (XSScreen.width - 32 - 20) / 3
|
||||||
|
let coverHeight = 142 / 107 * width
|
||||||
|
let height = coverHeight + 40
|
||||||
|
|
||||||
|
let layout = UICollectionViewFlowLayout()
|
||||||
|
layout.itemSize = .init(width: floor(width), height: height)
|
||||||
|
layout.minimumLineSpacing = 10
|
||||||
|
layout.minimumInteritemSpacing = 10
|
||||||
|
layout.sectionInset = .init(top: 0, left: 16, bottom: 0, right: 16)
|
||||||
|
layout.headerReferenceSize = .init(width: XSScreen.width, height: 1)
|
||||||
|
return layout
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var collectionView: XSCollectionView = {
|
||||||
|
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.dataSource = self
|
||||||
|
collectionView.showsHorizontalScrollIndicator = false
|
||||||
|
collectionView.contentInset = .init(top: 0, left: 0, bottom: XSScreen.customTabBarHeight + 10, right: 0)
|
||||||
|
collectionView.register(XSHomeCategoriesCell.self, forCellWithReuseIdentifier: "cell")
|
||||||
|
collectionView.register(XSHomeCategoriesHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "header")
|
||||||
|
return collectionView
|
||||||
|
}()
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await requestCategories()
|
||||||
|
await requestCategoryData(page: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomeCategoriesViewController {
|
||||||
|
private func xs_setupUI() {
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.left.right.bottom.equalToSuperview()
|
||||||
|
make.top.equalToSuperview().offset(self.topHeight + 15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: UICollectionViewDelegate UICollectionViewDataSource
|
||||||
|
extension XSHomeCategoriesViewController: UICollectionViewDelegate, UICollectionViewDataSource {
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! XSHomeCategoriesCell
|
||||||
|
cell.model = self.dataArr[indexPath.row]
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
return self.dataArr.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
|
||||||
|
let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "header", for: indexPath) as! XSHomeCategoriesHeaderView
|
||||||
|
view.didChangeHeight = { [weak self] height in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.layout.headerReferenceSize = .init(width: XSScreen.width, height: height)
|
||||||
|
}
|
||||||
|
view.didSelected = { [weak self] index in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.categoryIndex = index
|
||||||
|
Task {
|
||||||
|
await self.requestCategoryData(page: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.dataArr = self.categoryArr
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
let model = self.dataArr[indexPath.row]
|
||||||
|
let vc = XSShortDetailViewController()
|
||||||
|
vc.shortId = model.short_play_id
|
||||||
|
self.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomeCategoriesViewController {
|
||||||
|
|
||||||
|
private func requestCategories() async {
|
||||||
|
guard let dataArr = await XSHomeAPI.requestCategoryList() else { return }
|
||||||
|
|
||||||
|
self.categoryArr = dataArr
|
||||||
|
self.layout.headerReferenceSize = .init(width: XSScreen.width, height: 1)
|
||||||
|
self.collectionView.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestCategoryData(page: Int) async {
|
||||||
|
guard self.categoryIndex < self.categoryArr.count else { return }
|
||||||
|
guard let id = self.categoryArr[categoryIndex].id else { return }
|
||||||
|
|
||||||
|
guard let dataArr = await XSHomeAPI.requestCategoryVideo(id: id, page: page) else { return }
|
||||||
|
|
||||||
|
if page == 1 {
|
||||||
|
self.dataArr.removeAll()
|
||||||
|
}
|
||||||
|
self.dataArr += dataArr
|
||||||
|
self.page = page
|
||||||
|
self.collectionView.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
24
XSeri/Class/Home/Controller/XSHomeChildViewController.swift
Normal file
24
XSeri/Class/Home/Controller/XSHomeChildViewController.swift
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
//
|
||||||
|
// XSHomeChildViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomeChildViewController: XSViewController {
|
||||||
|
|
||||||
|
var topHeight: CGFloat {
|
||||||
|
return XSScreen.safeTop + 95
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
self.view.backgroundColor = .clear
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
180
XSeri/Class/Home/Controller/XSHomeNewViewController.swift
Normal file
180
XSeri/Class/Home/Controller/XSHomeNewViewController.swift
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
//
|
||||||
|
// XSHomeNewViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomeNewViewController: XSHomeChildViewController {
|
||||||
|
|
||||||
|
private lazy var dataArr: [XSShortModel] = []
|
||||||
|
private lazy var page: Int = 1
|
||||||
|
|
||||||
|
|
||||||
|
private lazy var layout: UICollectionViewCompositionalLayout = {
|
||||||
|
let layout = UICollectionViewCompositionalLayout { section, environment in
|
||||||
|
|
||||||
|
|
||||||
|
if section == 0 {
|
||||||
|
let headerItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(32)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
|
||||||
|
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .absolute(210), heightDimension: .absolute(360)), subitems: [item])
|
||||||
|
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||||
|
layoutSection.interGroupSpacing = 15
|
||||||
|
layoutSection.orthogonalScrollingBehavior = .continuous
|
||||||
|
layoutSection.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
|
||||||
|
layoutSection.boundarySupplementaryItems = [headerItem]
|
||||||
|
return layoutSection
|
||||||
|
} else {
|
||||||
|
let headerItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(57)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
|
||||||
|
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(146)), subitems: [item])
|
||||||
|
|
||||||
|
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||||
|
layoutSection.interGroupSpacing = 12
|
||||||
|
layoutSection.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
|
||||||
|
layoutSection.boundarySupplementaryItems = [headerItem]
|
||||||
|
return layoutSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return layout
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var collectionView: XSCollectionView = {
|
||||||
|
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.dataSource = self
|
||||||
|
collectionView.contentInset = .init(top: 0, left: 0, bottom: XSScreen.customTabBarHeight + 10, right: 0)
|
||||||
|
collectionView.xs_addRefreshHeader { [weak self] in
|
||||||
|
self?.handleHeaderRefresh(nil)
|
||||||
|
}
|
||||||
|
collectionView.xs_addRefreshFooter(insetBottom: 0) { [weak self] in
|
||||||
|
self?.handleFooterRefresh(nil)
|
||||||
|
}
|
||||||
|
collectionView.register(XSHomeNewBigCell.self, forCellWithReuseIdentifier: "XSHomeNewBigCell")
|
||||||
|
collectionView.register(XSHomeNewCell.self, forCellWithReuseIdentifier: "XSHomeNewCell")
|
||||||
|
collectionView.register(XSHomeNewTitleView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "title")
|
||||||
|
return collectionView
|
||||||
|
}()
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await requestDataArr(page: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func handleHeaderRefresh(_ completer: (() -> Void)?) {
|
||||||
|
Task {
|
||||||
|
await requestDataArr(page: 1)
|
||||||
|
self.collectionView.xs_endHeaderRefreshing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func handleFooterRefresh(_ completer: (() -> Void)?) {
|
||||||
|
Task {
|
||||||
|
await requestDataArr(page: self.page + 1)
|
||||||
|
self.collectionView.xs_endFooterRefreshing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomeNewViewController {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.left.right.bottom.equalToSuperview()
|
||||||
|
make.top.equalToSuperview().offset(self.topHeight + 13)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: UICollectionViewDelegate UICollectionViewDataSource
|
||||||
|
extension XSHomeNewViewController: UICollectionViewDelegate, UICollectionViewDataSource {
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
|
var model: XSShortModel
|
||||||
|
if indexPath.section == 0 {
|
||||||
|
model = self.dataArr[indexPath.row]
|
||||||
|
} else {
|
||||||
|
model = self.dataArr[indexPath.row + 3]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if indexPath.section == 0 {
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSHomeNewBigCell", for: indexPath) as! XSHomeNewBigCell
|
||||||
|
cell.model = model
|
||||||
|
return cell
|
||||||
|
} else {
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSHomeNewCell", for: indexPath) as! XSHomeNewCell
|
||||||
|
cell.model = model
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
let totalCount = self.dataArr.count
|
||||||
|
if section == 0 {
|
||||||
|
return min(3, totalCount)
|
||||||
|
} else {
|
||||||
|
return totalCount - 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberOfSections(in collectionView: UICollectionView) -> Int {
|
||||||
|
if self.dataArr.count > 3 {
|
||||||
|
return 2
|
||||||
|
} else if self.dataArr.count > 0 {
|
||||||
|
return 1
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
|
||||||
|
let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "title", for: indexPath) as! XSHomeNewTitleView
|
||||||
|
view.titleLabel.text = "New Releases".localized
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
var model: XSShortModel
|
||||||
|
if indexPath.section == 0 {
|
||||||
|
model = self.dataArr[indexPath.row]
|
||||||
|
} else {
|
||||||
|
model = self.dataArr[indexPath.row + 3]
|
||||||
|
}
|
||||||
|
let vc = XSShortDetailViewController()
|
||||||
|
vc.shortId = model.short_play_id
|
||||||
|
self.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomeNewViewController {
|
||||||
|
|
||||||
|
private func requestDataArr(page: Int) async {
|
||||||
|
guard let list = await XSHomeAPI.requestHomeNew(page: page) else { return }
|
||||||
|
if page == 1 {
|
||||||
|
self.dataArr.removeAll()
|
||||||
|
}
|
||||||
|
self.page = page
|
||||||
|
self.dataArr += list
|
||||||
|
|
||||||
|
self.collectionView.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
165
XSeri/Class/Home/Controller/XSHomePopularViewController.swift
Normal file
165
XSeri/Class/Home/Controller/XSHomePopularViewController.swift
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
//
|
||||||
|
// XSHomePopularViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomePopularViewController: XSHomeChildViewController {
|
||||||
|
|
||||||
|
private lazy var viewModel = XSHomeViewModel()
|
||||||
|
|
||||||
|
private lazy var layout: XSWaterfallFlowLayout = {
|
||||||
|
let layout = XSWaterfallFlowLayout()
|
||||||
|
layout.delegate = self
|
||||||
|
return layout
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var collectionView: XSCollectionView = {
|
||||||
|
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: self.layout)
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.dataSource = self
|
||||||
|
collectionView.contentInset = .init(top: 0, left: 0, bottom: XSScreen.customTabBarHeight + 10, right: 0)
|
||||||
|
collectionView.xs_addRefreshHeader { [weak self] in
|
||||||
|
self?.handleHeaderRefresh(nil)
|
||||||
|
}
|
||||||
|
collectionView.register(XSHomePopularCell.self, forCellWithReuseIdentifier: "XSHomePopularCell")
|
||||||
|
collectionView.register(XSHomePopularBigCell.self, forCellWithReuseIdentifier: "XSHomePopularBigCell")
|
||||||
|
return collectionView
|
||||||
|
}()
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(networkStatusDidChangeNotification), name: XSNetworkMonitorManager.networkStatusDidChangeNotification, object: nil)
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await self.requestDataArr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func handleHeaderRefresh(_ completer: (() -> Void)?) {
|
||||||
|
Task {
|
||||||
|
await self.requestDataArr()
|
||||||
|
self.collectionView.xs_endHeaderRefreshing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func networkStatusDidChangeNotification() {
|
||||||
|
guard XSNetworkMonitorManager.manager.isReachable == true else { return }
|
||||||
|
guard self.viewModel.dataArr.isEmpty else { return }
|
||||||
|
Task {
|
||||||
|
await self.requestDataArr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomePopularViewController {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.left.right.bottom.equalToSuperview()
|
||||||
|
make.top.equalToSuperview().offset(self.topHeight + 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: UICollectionViewDelegate UICollectionViewDataSource
|
||||||
|
extension XSHomePopularViewController: UICollectionViewDelegate, UICollectionViewDataSource {
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
|
let itemModel = self.viewModel.dataArr[indexPath.section]
|
||||||
|
let model = itemModel.list?[indexPath.row]
|
||||||
|
|
||||||
|
if itemModel.module_key == .week_highest_recommend {
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSHomePopularCell", for: indexPath) as! XSHomePopularCell
|
||||||
|
cell.model = model
|
||||||
|
return cell
|
||||||
|
} else {
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSHomePopularBigCell", for: indexPath) as! XSHomePopularBigCell
|
||||||
|
cell.model = model
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
return self.viewModel.dataArr[section].list?.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberOfSections(in collectionView: UICollectionView) -> Int {
|
||||||
|
return self.viewModel.dataArr.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
let itemModel = self.viewModel.dataArr[indexPath.section]
|
||||||
|
let model = itemModel.list?[indexPath.row]
|
||||||
|
let vc = XSShortDetailViewController()
|
||||||
|
vc.shortId = model?.short_play_id
|
||||||
|
self.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: XSWaterfallMutiSectionDelegate
|
||||||
|
extension XSHomePopularViewController: XSWaterfallMutiSectionDelegate {
|
||||||
|
func heightForRowAtIndexPath(collectionView collection: UICollectionView, layout: XSWaterfallFlowLayout, indexPath: IndexPath, itemWidth: CGFloat) -> CGFloat {
|
||||||
|
if indexPath.section == 0 {
|
||||||
|
return 146 + 57
|
||||||
|
} else {
|
||||||
|
return 219 + 92
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnNumber(collectionView collection: UICollectionView, layout: XSWaterfallFlowLayout, section: Int) -> Int {
|
||||||
|
if section == 0 {
|
||||||
|
return 3
|
||||||
|
} else {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insetForSection(collectionView collection: UICollectionView, layout: XSWaterfallFlowLayout, section: Int) -> UIEdgeInsets {
|
||||||
|
return .init(top: 0, left: 16, bottom: 0, right: 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
func interitemSpacing(collectionView collection: UICollectionView, layout: XSWaterfallFlowLayout, section: Int) -> CGFloat {
|
||||||
|
return 13
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineSpacing(collectionView collection: UICollectionView, layout: XSWaterfallFlowLayout, section: Int) -> CGFloat {
|
||||||
|
return 16
|
||||||
|
}
|
||||||
|
|
||||||
|
func spacingWithLastSection(collectionView collection: UICollectionView, layout: XSWaterfallFlowLayout, section: Int) -> CGFloat {
|
||||||
|
if section > 0 {
|
||||||
|
return 26
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomePopularViewController {
|
||||||
|
|
||||||
|
private func requestDataArr() async {
|
||||||
|
await self.viewModel.requestHomeData()
|
||||||
|
self.collectionView.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
111
XSeri/Class/Home/Controller/XSHomeRankingsViewController.swift
Normal file
111
XSeri/Class/Home/Controller/XSHomeRankingsViewController.swift
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
//
|
||||||
|
// XSHomeRankingsViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomeRankingsViewController: XSHomeChildViewController {
|
||||||
|
|
||||||
|
private lazy var dataArr: [XSShortModel] = []
|
||||||
|
|
||||||
|
private lazy var bgView: UIImageView = {
|
||||||
|
let imageView = UIImageView(image: UIImage(named: "rankings_bg_image"))
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var layout: UICollectionViewLayout = {
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
|
||||||
|
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(84)), subitems: [item])
|
||||||
|
|
||||||
|
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||||
|
layoutSection.interGroupSpacing = 10
|
||||||
|
layoutSection.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
|
||||||
|
|
||||||
|
let layout = UICollectionViewCompositionalLayout(section: layoutSection)
|
||||||
|
return layout
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var collectionView: XSCollectionView = {
|
||||||
|
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.dataSource = self
|
||||||
|
collectionView.contentInset = .init(top: 0, left: 0, bottom: XSScreen.customTabBarHeight + 10, right: 0)
|
||||||
|
collectionView.xs_addRefreshHeader { [weak self] in
|
||||||
|
self?.handleHeaderRefresh(nil)
|
||||||
|
}
|
||||||
|
collectionView.register(XSHomeRankingsCell.self, forCellWithReuseIdentifier: "cell")
|
||||||
|
return collectionView
|
||||||
|
}()
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await requestDataArr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func handleHeaderRefresh(_ completer: (() -> Void)?) {
|
||||||
|
Task {
|
||||||
|
await requestDataArr()
|
||||||
|
self.collectionView.xs_endHeaderRefreshing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomeRankingsViewController {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
view.addSubview(bgView)
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
|
||||||
|
bgView.snp.makeConstraints { make in
|
||||||
|
make.left.right.top.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.left.right.bottom.equalToSuperview()
|
||||||
|
make.top.equalToSuperview().offset(self.topHeight + 15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: UICollectionViewDelegate UICollectionViewDataSource
|
||||||
|
extension XSHomeRankingsViewController: UICollectionViewDelegate, UICollectionViewDataSource {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! XSHomeRankingsCell
|
||||||
|
cell.num = indexPath.row + 1
|
||||||
|
cell.model = self.dataArr[indexPath.row]
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
return self.dataArr.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
let model = self.dataArr[indexPath.row]
|
||||||
|
let vc = XSShortDetailViewController()
|
||||||
|
vc.shortId = model.short_play_id
|
||||||
|
self.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomeRankingsViewController {
|
||||||
|
|
||||||
|
private func requestDataArr() async {
|
||||||
|
guard let list = await XSHomeAPI.requestHomeRankings() else { return }
|
||||||
|
|
||||||
|
self.dataArr = list
|
||||||
|
self.collectionView.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
131
XSeri/Class/Home/Controller/XSHomeViewController.swift
Normal file
131
XSeri/Class/Home/Controller/XSHomeViewController.swift
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
//
|
||||||
|
// XSHomeViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
import JXSegmentedView
|
||||||
|
|
||||||
|
class XSHomeViewController: XSViewController {
|
||||||
|
|
||||||
|
private let titles: [String] = [
|
||||||
|
"Popular".localized,
|
||||||
|
"New".localized,
|
||||||
|
"Rankings".localized,
|
||||||
|
"Categories".localized,
|
||||||
|
]
|
||||||
|
|
||||||
|
private let viewControllers: [XSViewController] = {
|
||||||
|
let vc1 = XSHomePopularViewController()
|
||||||
|
let vc2 = XSHomeNewViewController()
|
||||||
|
let vc3 = XSHomeRankingsViewController()
|
||||||
|
let vc4 = XSHomeCategoriesViewController()
|
||||||
|
return [vc1, vc2, vc3, vc4]
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: - UI Components
|
||||||
|
private lazy var searchButton: XSHomeSearchButton = {
|
||||||
|
let button = XSHomeSearchButton()
|
||||||
|
button.addAction(UIAction(handler: { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let vc = XSSearchViewController()
|
||||||
|
self.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
}), for: .touchUpInside)
|
||||||
|
return button
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Segmented 数据源
|
||||||
|
private lazy var segmentedDataSource: JXSegmentedTitleDataSource = {
|
||||||
|
let dataSource = JXSegmentedTitleDataSource()
|
||||||
|
dataSource.titles = self.titles
|
||||||
|
dataSource.titleNormalColor = UIColor.white.withAlphaComponent(0.55)
|
||||||
|
dataSource.titleSelectedColor = .white
|
||||||
|
dataSource.titleNormalFont = .font(ofSize: 16, weight: .medium)
|
||||||
|
dataSource.titleSelectedFont = .font(ofSize: 16, weight: .semibold)
|
||||||
|
dataSource.isTitleColorGradientEnabled = true
|
||||||
|
dataSource.itemSpacing = 24
|
||||||
|
return dataSource
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Segmented 视图 (Tab 栏)
|
||||||
|
private lazy var segmentedView: JXSegmentedView = {
|
||||||
|
let view = JXSegmentedView()
|
||||||
|
view.dataSource = segmentedDataSource
|
||||||
|
view.delegate = self
|
||||||
|
view.listContainer = listContainerView
|
||||||
|
view.contentEdgeInsetLeft = 16
|
||||||
|
view.contentEdgeInsetRight = 16
|
||||||
|
view.backgroundColor = .clear
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// 列表容器 (内容展示)
|
||||||
|
private lazy var listContainerView: JXSegmentedListContainerView = {
|
||||||
|
return JXSegmentedListContainerView(dataSource: self)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: - Life Cycle
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
self.navigationController?.setNavigationBarHidden(true, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UI Setup
|
||||||
|
extension XSHomeViewController {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
view.addSubview(listContainerView)
|
||||||
|
view.addSubview(searchButton)
|
||||||
|
view.addSubview(segmentedView)
|
||||||
|
|
||||||
|
searchButton.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview().offset(16)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.top.equalToSuperview().offset(XSScreen.safeTop + 15)
|
||||||
|
make.height.equalTo(32)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
segmentedView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview()
|
||||||
|
make.right.equalToSuperview()
|
||||||
|
make.top.equalTo(searchButton.snp.bottom).offset(10)
|
||||||
|
make.height.equalTo(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
listContainerView.snp.makeConstraints { make in
|
||||||
|
make.left.right.bottom.equalToSuperview()
|
||||||
|
make.top.equalToSuperview()
|
||||||
|
// make.top.equalTo(segmentedView.snp.bottom).offset(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JXSegmentedViewDelegate
|
||||||
|
extension XSHomeViewController: JXSegmentedViewDelegate {
|
||||||
|
func segmentedView(_ segmentedView: JXSegmentedView, didSelectedItemAt index: Int) {
|
||||||
|
// Tab 切换回调
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JXSegmentedListContainerViewDataSource
|
||||||
|
extension XSHomeViewController: JXSegmentedListContainerViewDataSource {
|
||||||
|
func numberOfLists(in listContainerView: JXSegmentedListContainerView) -> Int {
|
||||||
|
return segmentedDataSource.titles.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func listContainerView(_ listContainerView: JXSegmentedListContainerView, initListAt index: Int) -> JXSegmentedListContainerViewListDelegate {
|
||||||
|
return viewControllers[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
390
XSeri/Class/Home/Controller/XSSearchViewController.swift
Normal file
390
XSeri/Class/Home/Controller/XSSearchViewController.swift
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
//
|
||||||
|
// XSSearchViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
import LYEmptyView
|
||||||
|
|
||||||
|
final class XSSearchViewController: XSViewController {
|
||||||
|
|
||||||
|
private enum Mode {
|
||||||
|
case idle
|
||||||
|
case suggest
|
||||||
|
case result
|
||||||
|
}
|
||||||
|
|
||||||
|
private let headerView = XSSearchHeaderView()
|
||||||
|
private let historyHotView = XSSearchHistoryHotView()
|
||||||
|
|
||||||
|
private lazy var suggestionCollectionView: XSCollectionView = {
|
||||||
|
let layout = UICollectionViewFlowLayout()
|
||||||
|
layout.minimumLineSpacing = 12
|
||||||
|
layout.minimumInteritemSpacing = 0
|
||||||
|
layout.sectionInset = .init(top: 0, left: 16, bottom: 0, right: 16)
|
||||||
|
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
collectionView.isScrollEnabled = true
|
||||||
|
collectionView.keyboardDismissMode = .onDrag
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.dataSource = self
|
||||||
|
collectionView.register(XSSearchSuggestionCell.self, forCellWithReuseIdentifier: "XSSearchSuggestionCell")
|
||||||
|
return collectionView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var resultCollectionView: XSCollectionView = {
|
||||||
|
let layout = UICollectionViewFlowLayout()
|
||||||
|
layout.minimumLineSpacing = 16
|
||||||
|
layout.minimumInteritemSpacing = 8
|
||||||
|
layout.sectionInset = .init(top: 0, left: 16, bottom: 0, right: 16)
|
||||||
|
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
collectionView.isScrollEnabled = true
|
||||||
|
collectionView.keyboardDismissMode = .onDrag
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.dataSource = self
|
||||||
|
collectionView.register(XSSearchResultCell.self, forCellWithReuseIdentifier: "XSSearchResultCell")
|
||||||
|
return collectionView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var history: [String] = []
|
||||||
|
private var suggestionItems: [XSShortModel] = []
|
||||||
|
private var resultItems: [XSShortModel] = []
|
||||||
|
private var currentSuggestKeyword = ""
|
||||||
|
private var currentResultKeyword = ""
|
||||||
|
|
||||||
|
private var mode: Mode = .idle {
|
||||||
|
didSet {
|
||||||
|
xs_updateVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var suggestionTask: Task<Void, Never>?
|
||||||
|
private var resultTask: Task<Void, Never>?
|
||||||
|
private var hotTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
suggestionTask?.cancel()
|
||||||
|
resultTask?.cancel()
|
||||||
|
hotTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
xs_bindData()
|
||||||
|
xs_loadHistory()
|
||||||
|
xs_loadHotSearches()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
self.navigationController?.setNavigationBarHidden(true, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
let _ = self.headerView.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchViewController {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
view.addSubview(headerView)
|
||||||
|
view.addSubview(historyHotView)
|
||||||
|
view.addSubview(suggestionCollectionView)
|
||||||
|
view.addSubview(resultCollectionView)
|
||||||
|
|
||||||
|
headerView.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(XSScreen.safeTop + 12)
|
||||||
|
make.left.equalToSuperview().offset(16)
|
||||||
|
make.right.equalToSuperview().offset(-16)
|
||||||
|
make.height.equalTo(38)
|
||||||
|
}
|
||||||
|
|
||||||
|
historyHotView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(headerView.snp.bottom).offset(22)
|
||||||
|
make.left.right.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestionCollectionView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(headerView.snp.bottom).offset(22)
|
||||||
|
make.left.right.equalToSuperview()
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
}
|
||||||
|
|
||||||
|
resultCollectionView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(headerView.snp.bottom).offset(22)
|
||||||
|
make.left.right.equalToSuperview()
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索结果为空时显示空白样式
|
||||||
|
resultCollectionView.ly_emptyView = XSEmpty.xs_emptyView()
|
||||||
|
// 联想为空时显示空白样式
|
||||||
|
suggestionCollectionView.ly_emptyView = XSEmpty.xs_emptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xs_bindData() {
|
||||||
|
headerView.placeholder = XSSearchData.searchPlaceholder
|
||||||
|
headerView.didTapBack = { [weak self] in
|
||||||
|
self?.navigationController?.popViewController(animated: true)
|
||||||
|
}
|
||||||
|
headerView.didBeginEditing = { [weak self] text in
|
||||||
|
guard let self = self else { return }
|
||||||
|
// 已经显示搜索结果时,不在进入编辑时立刻切换联想
|
||||||
|
if self.mode == .result { return }
|
||||||
|
self.xs_handleSuggest(text)
|
||||||
|
}
|
||||||
|
headerView.didChangeText = { [weak self] text in
|
||||||
|
self?.xs_handleSuggest(text)
|
||||||
|
}
|
||||||
|
headerView.didTapSearch = { [weak self] text in
|
||||||
|
self?.xs_submitSearch(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
historyHotView.historyTitle = XSSearchData.recentTitle
|
||||||
|
historyHotView.didTapClear = { [weak self] in
|
||||||
|
self?.xs_clearHistory()
|
||||||
|
}
|
||||||
|
historyHotView.didSelectHistory = { [weak self] text in
|
||||||
|
self?.headerView.text = text
|
||||||
|
self?.xs_submitSearch(text)
|
||||||
|
}
|
||||||
|
historyHotView.didSelectHot = { [weak self] model in
|
||||||
|
self?.xs_pushDetail(model: model)
|
||||||
|
}
|
||||||
|
historyHotView.hotTitle = XSSearchData.hotSearchesTitle
|
||||||
|
xs_updateVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data
|
||||||
|
extension XSSearchViewController {
|
||||||
|
|
||||||
|
private func xs_loadHotSearches() {
|
||||||
|
hotTask?.cancel()
|
||||||
|
hotTask = Task { [weak self] in
|
||||||
|
// 热门榜单数据来源接口
|
||||||
|
let hotList = await XSHomeAPI.requestHotSearchList() ?? []
|
||||||
|
let topList = await XSHomeAPI.requestTopSearchList() ?? []
|
||||||
|
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
self?.xs_updateHotSection(hotList: Array(hotList.prefix(5)), topList: Array(topList.prefix(5)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xs_updateHotSection(hotList: [XSShortModel], topList: [XSShortModel]) {
|
||||||
|
|
||||||
|
var arr: [XSSearchHotSection] = []
|
||||||
|
|
||||||
|
if !hotList.isEmpty {
|
||||||
|
arr.append(XSSearchHotSection(icon: UIImage(named: "hot_icon_03"), title: XSSearchData.hotSectionTitles[0], style: .gold, items: hotList))
|
||||||
|
}
|
||||||
|
if !topList.isEmpty {
|
||||||
|
arr.append(XSSearchHotSection(icon: UIImage(named: "hot_icon_04"), title: XSSearchData.hotSectionTitles[1], style: .purple, items: topList))
|
||||||
|
}
|
||||||
|
historyHotView.hotSections = arr
|
||||||
|
|
||||||
|
xs_updateVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xs_fetchSuggestions(_ keyword: String) {
|
||||||
|
suggestionTask?.cancel()
|
||||||
|
let expectedKeyword = keyword
|
||||||
|
suggestionTask = Task { [weak self] in
|
||||||
|
// 联想词同样走搜索接口
|
||||||
|
let list = await XSHomeAPI.requestSearch(text: keyword) ?? []
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self = self,
|
||||||
|
self.mode == .suggest,
|
||||||
|
self.currentSuggestKeyword == expectedKeyword else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.suggestionItems = list
|
||||||
|
self.suggestionCollectionView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xs_fetchResults(_ keyword: String) {
|
||||||
|
resultTask?.cancel()
|
||||||
|
let expectedKeyword = keyword
|
||||||
|
resultTask = Task { [weak self] in
|
||||||
|
// 搜索结果走搜索接口
|
||||||
|
let list = await XSHomeAPI.requestSearch(text: keyword) ?? []
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self = self,
|
||||||
|
self.mode == .result,
|
||||||
|
self.currentResultKeyword == expectedKeyword else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.resultItems = list
|
||||||
|
self.resultCollectionView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
extension XSSearchViewController {
|
||||||
|
|
||||||
|
private func xs_handleSuggest(_ text: String?) {
|
||||||
|
let keyword = (text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if keyword.isEmpty {
|
||||||
|
suggestionTask?.cancel()
|
||||||
|
resultTask?.cancel()
|
||||||
|
currentSuggestKeyword = ""
|
||||||
|
mode = .idle
|
||||||
|
suggestionItems = []
|
||||||
|
suggestionCollectionView.reloadData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resultTask?.cancel()
|
||||||
|
resultItems = []
|
||||||
|
resultCollectionView.reloadData()
|
||||||
|
resultCollectionView.isHidden = true
|
||||||
|
currentSuggestKeyword = keyword
|
||||||
|
mode = .suggest
|
||||||
|
suggestionCollectionView.isHidden = false
|
||||||
|
xs_fetchSuggestions(keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xs_submitSearch(_ text: String?) {
|
||||||
|
let keyword = (text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !keyword.isEmpty else {
|
||||||
|
mode = .idle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestionTask?.cancel()
|
||||||
|
suggestionItems = []
|
||||||
|
suggestionCollectionView.reloadData()
|
||||||
|
suggestionCollectionView.isHidden = true
|
||||||
|
currentResultKeyword = keyword
|
||||||
|
view.endEditing(true)
|
||||||
|
mode = .result
|
||||||
|
xs_addHistory(keyword)
|
||||||
|
xs_fetchResults(keyword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - History
|
||||||
|
extension XSSearchViewController {
|
||||||
|
|
||||||
|
private func xs_loadHistory() {
|
||||||
|
history = UserDefaults.standard.stringArray(forKey: XSSearchData.historyKey) ?? []
|
||||||
|
historyHotView.historyTags = history
|
||||||
|
xs_updateVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xs_addHistory(_ keyword: String) {
|
||||||
|
// 搜索历史本地缓存,最多保留 10 条
|
||||||
|
history.removeAll { $0.caseInsensitiveCompare(keyword) == .orderedSame }
|
||||||
|
history.insert(keyword, at: 0)
|
||||||
|
if history.count > 10 {
|
||||||
|
history = Array(history.prefix(10))
|
||||||
|
}
|
||||||
|
UserDefaults.standard.set(history, forKey: XSSearchData.historyKey)
|
||||||
|
historyHotView.historyTags = history
|
||||||
|
xs_updateVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xs_clearHistory() {
|
||||||
|
history = []
|
||||||
|
UserDefaults.standard.removeObject(forKey: XSSearchData.historyKey)
|
||||||
|
historyHotView.historyTags = []
|
||||||
|
xs_updateVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout
|
||||||
|
extension XSSearchViewController {
|
||||||
|
|
||||||
|
private func xs_updateVisibility() {
|
||||||
|
switch mode {
|
||||||
|
case .idle:
|
||||||
|
historyHotView.isHidden = !historyHotView.hasContent
|
||||||
|
suggestionCollectionView.isHidden = true
|
||||||
|
resultCollectionView.isHidden = true
|
||||||
|
headerView.style = .home
|
||||||
|
case .suggest:
|
||||||
|
historyHotView.isHidden = true
|
||||||
|
suggestionCollectionView.isHidden = false
|
||||||
|
resultCollectionView.isHidden = true
|
||||||
|
headerView.style = .input
|
||||||
|
case .result:
|
||||||
|
historyHotView.isHidden = true
|
||||||
|
suggestionCollectionView.isHidden = true
|
||||||
|
resultCollectionView.isHidden = false
|
||||||
|
headerView.style = .input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UICollectionViewDelegate UICollectionViewDataSource
|
||||||
|
extension XSSearchViewController: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
if collectionView == suggestionCollectionView {
|
||||||
|
return suggestionItems.count
|
||||||
|
}
|
||||||
|
return resultItems.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
|
if collectionView == suggestionCollectionView {
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSSearchSuggestionCell", for: indexPath) as! XSSearchSuggestionCell
|
||||||
|
let item = suggestionItems[indexPath.row]
|
||||||
|
let keyword = headerView.text ?? ""
|
||||||
|
cell.configure(model: item, keyword: keyword)
|
||||||
|
return cell
|
||||||
|
} else {
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSSearchResultCell", for: indexPath) as! XSSearchResultCell
|
||||||
|
cell.configure(model: resultItems[indexPath.row])
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
if collectionView == suggestionCollectionView {
|
||||||
|
let item = suggestionItems[indexPath.row]
|
||||||
|
xs_pushDetail(model: item)
|
||||||
|
} else if collectionView == resultCollectionView {
|
||||||
|
let item = resultItems[indexPath.row]
|
||||||
|
xs_pushDetail(model: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||||
|
if collectionView == suggestionCollectionView {
|
||||||
|
let width = XSScreen.width - 32
|
||||||
|
return CGSize(width: floor(width), height: 104)
|
||||||
|
} else {
|
||||||
|
let width = (XSScreen.width - 32 - 16) / 3
|
||||||
|
return CGSize(width: floor(width), height: 186)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detail
|
||||||
|
extension XSSearchViewController {
|
||||||
|
|
||||||
|
private func xs_pushDetail(model: XSShortModel) {
|
||||||
|
let shortId = model.short_play_id ?? model.short_play_video_id ?? model.id
|
||||||
|
guard let shortId = shortId, !shortId.isEmpty else { return }
|
||||||
|
let controller = XSShortDetailViewController()
|
||||||
|
controller.shortId = shortId
|
||||||
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
XSeri/Class/Home/Model/XSCategoryModel.swift
Normal file
22
XSeri/Class/Home/Model/XSCategoryModel.swift
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// XSCategoryModel.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SmartCodable
|
||||||
|
|
||||||
|
struct XSCategoryModel: SmartCodable {
|
||||||
|
|
||||||
|
var id: String?
|
||||||
|
var name: String?
|
||||||
|
|
||||||
|
static func mappingForKey() -> [SmartKeyTransformer]? {
|
||||||
|
return [
|
||||||
|
CodingKeys.id <--- ["id", "category_id"],
|
||||||
|
CodingKeys.name <--- ["name", "category_name"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
XSeri/Class/Home/Model/XSHomeData.swift
Normal file
16
XSeri/Class/Home/Model/XSHomeData.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct XSHomeData {
|
||||||
|
|
||||||
|
/// 首页顶部 Tab 分类
|
||||||
|
static let topTabs = [
|
||||||
|
"Popular",
|
||||||
|
"New",
|
||||||
|
"Rankings",
|
||||||
|
"Categories",
|
||||||
|
"aaaaa",
|
||||||
|
"gfgfggg",
|
||||||
|
"ffffff"
|
||||||
|
]
|
||||||
|
|
||||||
|
}
|
||||||
66
XSeri/Class/Home/Model/XSHomeModuleItem.swift
Normal file
66
XSeri/Class/Home/Model/XSHomeModuleItem.swift
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
//
|
||||||
|
// XSHomeModuleItem.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SmartCodable
|
||||||
|
|
||||||
|
class XSHomeModuleItem: NSObject, SmartCodable {
|
||||||
|
|
||||||
|
enum ModuleKey: String, SmartCaseDefaultable {
|
||||||
|
case banner = "home_banner"
|
||||||
|
case v3_recommand = "home_v3_recommand"
|
||||||
|
///分类推荐
|
||||||
|
case cagetory_recommand = "home_cagetory_recommand"
|
||||||
|
case week_ranking = "week_ranking"
|
||||||
|
///跑马灯
|
||||||
|
case marquee = "marquee"
|
||||||
|
|
||||||
|
case new_recommand = "new_recommand"
|
||||||
|
|
||||||
|
case week_highest_recommend = "week_highest_recommend"
|
||||||
|
}
|
||||||
|
|
||||||
|
required override init() { }
|
||||||
|
|
||||||
|
var module_key: ModuleKey?
|
||||||
|
var title: String?
|
||||||
|
var list: [XSShortModel]?
|
||||||
|
|
||||||
|
@SmartAny
|
||||||
|
var data: Any?
|
||||||
|
|
||||||
|
@IgnoredKey
|
||||||
|
var iconImage: UIImage?
|
||||||
|
|
||||||
|
@IgnoredKey
|
||||||
|
var br_cellHeight: CGFloat?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
func didFinishMapping() {
|
||||||
|
if let data = data as? [[String : Any]] {
|
||||||
|
self.list = [XSShortModel].deserialize(from: data)
|
||||||
|
} else if let data = data as? [String : Any] {
|
||||||
|
var dataList: [[String : Any]]?
|
||||||
|
if let list = data["list"] as? [[String : Any]] {
|
||||||
|
self.title = data["title"] as? String
|
||||||
|
dataList = list
|
||||||
|
|
||||||
|
} else if let list = data["shortPlayList"] as? [[String : Any]] {
|
||||||
|
self.title = data["category_name"] as? String
|
||||||
|
dataList = list
|
||||||
|
}
|
||||||
|
|
||||||
|
if let dataList = dataList {
|
||||||
|
self.list = [XSShortModel].deserialize(from: dataList)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
41
XSeri/Class/Home/Model/XSSearchData.swift
Normal file
41
XSeri/Class/Home/Model/XSSearchData.swift
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// XSSearchData.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 搜索页静态数据,集中管理文字与列表,避免视图中硬编码
|
||||||
|
struct XSSearchData {
|
||||||
|
|
||||||
|
/// 搜索框占位文案
|
||||||
|
static let searchPlaceholder = "Love in Ashes is he".localized
|
||||||
|
/// 最近搜索标题
|
||||||
|
static let recentTitle = "Recent".localized
|
||||||
|
/// 热门搜索标题
|
||||||
|
static let hotSearchesTitle = "Hot Searches".localized
|
||||||
|
/// 热门榜单子标题
|
||||||
|
static let hotSectionTitles = [
|
||||||
|
"Rising Popularity".localized,
|
||||||
|
"Top-Rated Charts".localized
|
||||||
|
]
|
||||||
|
/// 搜索历史存储键
|
||||||
|
static let historyKey = "xs_search_history"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 榜单板块模型
|
||||||
|
struct XSSearchHotSection {
|
||||||
|
let icon: UIImage?
|
||||||
|
let title: String
|
||||||
|
let style: XSSearchHotSectionStyle
|
||||||
|
let items: [XSShortModel]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 榜单样式类型
|
||||||
|
enum XSSearchHotSectionStyle {
|
||||||
|
case gold
|
||||||
|
case purple
|
||||||
|
}
|
||||||
69
XSeri/Class/Home/View/XSHomeCategoriesCell.swift
Normal file
69
XSeri/Class/Home/View/XSHomeCategoriesCell.swift
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
//
|
||||||
|
// XSHomeCategoriesCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomeCategoriesCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
var model: XSShortModel? {
|
||||||
|
didSet {
|
||||||
|
coverImageView.xs_setImage(model?.image_url)
|
||||||
|
titleLabel.text = model?.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var coverImageView: XSImageView = {
|
||||||
|
let imageView = XSImageView()
|
||||||
|
imageView.layer.cornerRadius = 10
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 10, weight: .semibold)
|
||||||
|
label.textColor = .white
|
||||||
|
label.numberOfLines = 2
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
contentView.layer.cornerRadius = 10
|
||||||
|
contentView.layer.masksToBounds = true
|
||||||
|
contentView.layer.borderWidth = 1
|
||||||
|
contentView.layer.borderColor = UIColor._6_D_71_E_0.withAlphaComponent(0.4).cgColor
|
||||||
|
|
||||||
|
xs_layoutUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomeCategoriesCell {
|
||||||
|
|
||||||
|
private func xs_layoutUI() {
|
||||||
|
contentView.addSubview(coverImageView)
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
|
||||||
|
coverImageView.snp.makeConstraints { make in
|
||||||
|
make.left.right.top.equalToSuperview()
|
||||||
|
make.bottom.equalToSuperview().offset(-40)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview().offset(5)
|
||||||
|
make.right.lessThanOrEqualToSuperview().offset(-5)
|
||||||
|
make.top.equalTo(coverImageView.snp.bottom).offset(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
121
XSeri/Class/Home/View/XSHomeCategoriesHeaderView.swift
Normal file
121
XSeri/Class/Home/View/XSHomeCategoriesHeaderView.swift
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
//
|
||||||
|
// XSHomeCategoriesHeaderView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import collection_view_layouts
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomeCategoriesHeaderView: UICollectionReusableView {
|
||||||
|
|
||||||
|
|
||||||
|
var didChangeHeight: ((_ height: CGFloat) -> Void)?
|
||||||
|
|
||||||
|
var didSelected: ((_ index: Int) -> Void)?
|
||||||
|
|
||||||
|
var dataArr: [XSCategoryModel] = [] {
|
||||||
|
didSet {
|
||||||
|
self.collectionView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentIndex: Int = 0 {
|
||||||
|
didSet {
|
||||||
|
self.collectionView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var layout: TagsLayout = {
|
||||||
|
let layout = TagsLayout()
|
||||||
|
layout.delegate = self
|
||||||
|
layout.cellsPadding = .init(horizontal: 12, vertical: 16)
|
||||||
|
layout.contentPadding = .init(horizontal: 16, vertical: 0)
|
||||||
|
return layout
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var collectionView: XSCollectionView = {
|
||||||
|
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
collectionView.isScrollEnabled = false
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.dataSource = self
|
||||||
|
collectionView.addObserver(self, forKeyPath: "contentSize", context: nil)
|
||||||
|
collectionView.register(XSHomeCategoriesTagsCell.self, forCellWithReuseIdentifier: "cell")
|
||||||
|
return collectionView
|
||||||
|
}()
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
self.collectionView.removeObserver(self, forKeyPath: "contentSize")
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||||
|
if keyPath == "contentSize" {
|
||||||
|
self.didChangeHeight?(self.collectionView.contentSize.height + 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomeCategoriesHeaderView {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
addSubview(collectionView)
|
||||||
|
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.left.right.equalToSuperview()
|
||||||
|
make.top.equalToSuperview()
|
||||||
|
make.bottom.equalToSuperview().offset(-14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: UICollectionViewDelegate UICollectionViewDataSource
|
||||||
|
extension XSHomeCategoriesHeaderView: UICollectionViewDelegate, UICollectionViewDataSource {
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! XSHomeCategoriesTagsCell
|
||||||
|
cell.xs_isSelected = currentIndex == indexPath.row
|
||||||
|
cell.model = self.dataArr[indexPath.row]
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
return self.dataArr.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
guard currentIndex != indexPath.row else { return }
|
||||||
|
self.currentIndex = indexPath.row
|
||||||
|
self.collectionView.reloadData()
|
||||||
|
|
||||||
|
self.didSelected?(indexPath.row)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: LayoutDelegate
|
||||||
|
extension XSHomeCategoriesHeaderView: LayoutDelegate {
|
||||||
|
|
||||||
|
func cellSize(indexPath: IndexPath) -> CGSize {
|
||||||
|
let model = self.dataArr[indexPath.row]
|
||||||
|
let text = model.name ?? ""
|
||||||
|
let width = text.size(XSHomeCategoriesTagsCell.textFont, .init(width: XSScreen.width, height: 27)).width
|
||||||
|
|
||||||
|
return .init(width: width + 24, height: 27)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
81
XSeri/Class/Home/View/XSHomeCategoriesTagsCell.swift
Normal file
81
XSeri/Class/Home/View/XSHomeCategoriesTagsCell.swift
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
//
|
||||||
|
// XSHomeCategoriesTagsCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/4.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomeCategoriesTagsCell: UICollectionViewCell {
|
||||||
|
static let textFont = UIFont.font(ofSize: 12, weight: .regular)
|
||||||
|
static let selectedTextFont = UIFont.font(ofSize: 12, weight: .bold)
|
||||||
|
static let textColor = UIColor.white
|
||||||
|
static let selectedTextColor = UIColor._282828
|
||||||
|
|
||||||
|
|
||||||
|
var model: XSCategoryModel? {
|
||||||
|
didSet {
|
||||||
|
titleLabel.text = model?.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var xs_isSelected: Bool = false {
|
||||||
|
didSet {
|
||||||
|
if xs_isSelected {
|
||||||
|
titleLabel.font = Self.selectedTextFont
|
||||||
|
titleLabel.textColor = Self.selectedTextColor
|
||||||
|
bgView.isHidden = false
|
||||||
|
} else {
|
||||||
|
titleLabel.font = Self.textFont
|
||||||
|
titleLabel.textColor = Self.textColor
|
||||||
|
bgView.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var bgView: UIImageView = {
|
||||||
|
let view = UIImageView(image: UIImage(named: "gradient_color_image_01"))
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
self.contentView.backgroundColor = .FFEFD_8.withAlphaComponent(0.15)
|
||||||
|
self.contentView.layer.masksToBounds = true
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
self.contentView.layer.cornerRadius = self.contentView.bounds.height / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomeCategoriesTagsCell {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
contentView.addSubview(bgView)
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
|
||||||
|
bgView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
103
XSeri/Class/Home/View/XSHomeNewBigCell.swift
Normal file
103
XSeri/Class/Home/View/XSHomeNewBigCell.swift
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
//
|
||||||
|
// XSHomeNewBigCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomeNewBigCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
var model: XSShortModel? {
|
||||||
|
didSet {
|
||||||
|
coverBigImageView.xs_setImage(model?.image_url)
|
||||||
|
coverImageView.xs_setImage(model?.image_url)
|
||||||
|
titleLabel.text = model?.name
|
||||||
|
categoryLabel.text = model?.category?.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var coverBigImageView: XSImageView = {
|
||||||
|
let imageView = XSImageView()
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var coverImageView: XSImageView = {
|
||||||
|
let imageView = XSImageView()
|
||||||
|
imageView.layer.cornerRadius = 4
|
||||||
|
imageView.layer.borderWidth = 1
|
||||||
|
imageView.layer.borderColor = UIColor.white.withAlphaComponent(0.25).cgColor
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .medium)
|
||||||
|
label.textColor = .white
|
||||||
|
label.numberOfLines = 2
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var categoryLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 12, weight: .regular)
|
||||||
|
label.textColor = .white.withAlphaComponent(0.5)
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
self.titleLabel.text = "What Goes Around Comes Around"
|
||||||
|
self.categoryLabel.text = "Antiquity"
|
||||||
|
|
||||||
|
contentView.backgroundColor = .white.withAlphaComponent(0.06)
|
||||||
|
contentView.layer.cornerRadius = 8
|
||||||
|
contentView.layer.masksToBounds = true
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomeNewBigCell {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
contentView.addSubview(coverBigImageView)
|
||||||
|
contentView.addSubview(coverImageView)
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
contentView.addSubview(categoryLabel)
|
||||||
|
|
||||||
|
coverBigImageView.snp.makeConstraints { make in
|
||||||
|
make.left.right.top.equalToSuperview()
|
||||||
|
make.height.equalTo(280)
|
||||||
|
}
|
||||||
|
|
||||||
|
coverImageView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview().offset(9)
|
||||||
|
make.top.equalTo(coverBigImageView.snp.bottom).offset(9)
|
||||||
|
make.width.equalTo(45)
|
||||||
|
make.height.equalTo(60)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(coverImageView.snp.right).offset(7)
|
||||||
|
make.right.lessThanOrEqualToSuperview().offset(-10)
|
||||||
|
make.top.equalTo(coverBigImageView.snp.bottom).offset(9)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(titleLabel)
|
||||||
|
make.right.lessThanOrEqualToSuperview().offset(-10)
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
154
XSeri/Class/Home/View/XSHomeNewCell.swift
Normal file
154
XSeri/Class/Home/View/XSHomeNewCell.swift
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
//
|
||||||
|
// XSHomeNewCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomeNewCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
var model: XSShortModel? {
|
||||||
|
didSet {
|
||||||
|
coverImageView.xs_setImage(model?.image_url)
|
||||||
|
titleLabel.text = model?.name
|
||||||
|
desLabel.text = model?.xs_description
|
||||||
|
epLabel.text = "## Episodes".localizedReplace(text: "\(model?.episode_total ?? 0)")
|
||||||
|
|
||||||
|
if let category = model?.category?.first, !category.isEmpty {
|
||||||
|
categoryView.isHidden = false
|
||||||
|
categoryLabel.text = category
|
||||||
|
} else {
|
||||||
|
categoryView.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if model?.tag_type == .hot {
|
||||||
|
tagView.isHidden = false
|
||||||
|
tagView.image = UIImage(named: "hot_icon_02")
|
||||||
|
} else if model?.tag_type == .new {
|
||||||
|
tagView.isHidden = false
|
||||||
|
tagView.image = UIImage(named: "new_icon_01")
|
||||||
|
} else {
|
||||||
|
tagView.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var coverImageView: XSImageView = {
|
||||||
|
let imageView = XSImageView()
|
||||||
|
imageView.layer.cornerRadius = 4
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var tagView: UIImageView = {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
imageView.xs_setCornerRadius(topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 4)
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 16, weight: .medium)
|
||||||
|
label.textColor = .white
|
||||||
|
label.numberOfLines = 2
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var epLabel: UILabel = {
|
||||||
|
let label = XSLabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .regular)
|
||||||
|
label.colorImage = UIImage(named: "gradient_color_image_01")
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var desLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .regular)
|
||||||
|
label.textColor = .white.withAlphaComponent(0.8)
|
||||||
|
label.numberOfLines = 2
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var categoryView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.layer.cornerRadius = 2
|
||||||
|
view.layer.masksToBounds = true
|
||||||
|
view.backgroundColor = .D_9_D_9_D_9.withAlphaComponent(0.16)
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var categoryLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 10, weight: .regular)
|
||||||
|
label.textColor = .white
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
titleLabel.text = "The Year You Were Gone fro...fdsafdafdafdsfsafdsafdasfasdfsa"
|
||||||
|
epLabel.text = "55 Episodes"
|
||||||
|
desLabel.text = "Maddie, a maid, seeks justice for her murdered father. She u...fdsafdasfsfdads"
|
||||||
|
categoryLabel.text = "Angst Romance"
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomeNewCell {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
contentView.addSubview(coverImageView)
|
||||||
|
coverImageView.addSubview(tagView)
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
contentView.addSubview(epLabel)
|
||||||
|
contentView.addSubview(desLabel)
|
||||||
|
contentView.addSubview(categoryView)
|
||||||
|
categoryView.addSubview(categoryLabel)
|
||||||
|
|
||||||
|
coverImageView.snp.makeConstraints { make in
|
||||||
|
make.left.top.bottom.equalToSuperview()
|
||||||
|
make.width.equalTo(105)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagView.snp.makeConstraints { make in
|
||||||
|
make.left.top.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(coverImageView.snp.right).offset(12)
|
||||||
|
make.right.lessThanOrEqualToSuperview()
|
||||||
|
make.top.equalToSuperview().offset(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
epLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(titleLabel)
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
desLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(titleLabel)
|
||||||
|
make.top.equalTo(epLabel.snp.bottom).offset(6)
|
||||||
|
make.right.lessThanOrEqualToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryView.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(titleLabel)
|
||||||
|
make.top.equalTo(desLabel.snp.bottom).offset(6)
|
||||||
|
make.height.equalTo(18)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.left.equalToSuperview().offset(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
34
XSeri/Class/Home/View/XSHomeNewTitleView.swift
Normal file
34
XSeri/Class/Home/View/XSHomeNewTitleView.swift
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
//
|
||||||
|
// XSHomeNewTitleView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomeNewTitleView: UICollectionReusableView {
|
||||||
|
|
||||||
|
lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .bold)
|
||||||
|
label.textColor = .FFDAA_4
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
addSubview(titleLabel)
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview()
|
||||||
|
make.bottom.equalToSuperview().offset(-14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
139
XSeri/Class/Home/View/XSHomePopularBigCell.swift
Normal file
139
XSeri/Class/Home/View/XSHomePopularBigCell.swift
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
//
|
||||||
|
// XSHomePopularBigCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomePopularBigCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
var model: XSShortModel? {
|
||||||
|
didSet {
|
||||||
|
coverImageView.xs_setImage(model?.image_url)
|
||||||
|
titleLabel.text = model?.name
|
||||||
|
desLabel.text = model?.xs_description
|
||||||
|
|
||||||
|
if let category = model?.category?.first, !category.isEmpty {
|
||||||
|
categoryLabel.text = category
|
||||||
|
categoryBgView.isHidden = false
|
||||||
|
} else {
|
||||||
|
categoryBgView.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if model?.tag_type == .hot {
|
||||||
|
tagView.isHidden = false
|
||||||
|
tagView.image = UIImage(named: "hot_icon_02")
|
||||||
|
} else if model?.tag_type == .new {
|
||||||
|
tagView.isHidden = false
|
||||||
|
tagView.image = UIImage(named: "new_icon_01")
|
||||||
|
} else {
|
||||||
|
tagView.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var coverImageView: XSImageView = {
|
||||||
|
let imageView = XSImageView()
|
||||||
|
imageView.layer.masksToBounds = true
|
||||||
|
imageView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||||
|
imageView.layer.cornerRadius = 4
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var tagView: UIImageView = {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
imageView.xs_setCornerRadius(topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 4)
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .bold)
|
||||||
|
label.textColor = .white
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var desLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 10, weight: .regular)
|
||||||
|
label.textColor = .white.withAlphaComponent(0.5)
|
||||||
|
label.numberOfLines = 2
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var categoryBgView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.layer.cornerRadius = 12
|
||||||
|
view.layer.masksToBounds = true
|
||||||
|
view.layer.borderWidth = 1
|
||||||
|
view.layer.borderColor = UIColor.white.withAlphaComponent(0.25).cgColor
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var categoryLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 12, weight: .regular)
|
||||||
|
label.textColor = .white
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomePopularBigCell {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
contentView.addSubview(coverImageView)
|
||||||
|
coverImageView.addSubview(tagView)
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
contentView.addSubview(desLabel)
|
||||||
|
contentView.addSubview(categoryBgView)
|
||||||
|
categoryBgView.addSubview(categoryLabel)
|
||||||
|
|
||||||
|
coverImageView.snp.makeConstraints { make in
|
||||||
|
make.left.right.top.equalToSuperview()
|
||||||
|
make.bottom.equalToSuperview().offset(-92)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagView.snp.makeConstraints { make in
|
||||||
|
make.left.top.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview()
|
||||||
|
make.right.lessThanOrEqualToSuperview()
|
||||||
|
make.top.equalTo(coverImageView.snp.bottom).offset(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
desLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview()
|
||||||
|
make.right.lessThanOrEqualToSuperview()
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryBgView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview()
|
||||||
|
make.right.lessThanOrEqualToSuperview()
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
|
make.height.equalTo(24)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.left.equalToSuperview().offset(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
101
XSeri/Class/Home/View/XSHomePopularCell.swift
Normal file
101
XSeri/Class/Home/View/XSHomePopularCell.swift
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
//
|
||||||
|
// XSHomePopularCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomePopularCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
var model: XSShortModel? {
|
||||||
|
didSet {
|
||||||
|
coverImageView.xs_setImage(model?.image_url)
|
||||||
|
titleLabel.text = model?.name
|
||||||
|
categoryLabel.text = model?.category?.first
|
||||||
|
|
||||||
|
if model?.tag_type == .hot {
|
||||||
|
tagView.isHidden = false
|
||||||
|
tagView.image = UIImage(named: "hot_icon_02")
|
||||||
|
} else if model?.tag_type == .new {
|
||||||
|
tagView.isHidden = false
|
||||||
|
tagView.image = UIImage(named: "new_icon_01")
|
||||||
|
} else {
|
||||||
|
tagView.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var coverImageView: XSImageView = {
|
||||||
|
let imageView = XSImageView()
|
||||||
|
imageView.layer.cornerRadius = 4
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var tagView: UIImageView = {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
imageView.xs_setCornerRadius(topLeft: 0, topRight: 0, bottomLeft: 4, bottomRight: 0)
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 12, weight: .regular)
|
||||||
|
label.textColor = .white
|
||||||
|
label.numberOfLines = 2
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var categoryLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 12, weight: .regular)
|
||||||
|
label.textColor = .white.withAlphaComponent(0.5)
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
categoryLabel.text = "Revenge"
|
||||||
|
titleLabel.text = "The Year You Were Gone frfdsafhdaskhfkashfkdsahfdask"
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomePopularCell {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
contentView.addSubview(coverImageView)
|
||||||
|
coverImageView.addSubview(tagView)
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
contentView.addSubview(categoryLabel)
|
||||||
|
|
||||||
|
coverImageView.snp.makeConstraints { make in
|
||||||
|
make.left.right.top.equalToSuperview()
|
||||||
|
make.bottom.equalToSuperview().offset(-57)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagView.snp.makeConstraints { make in
|
||||||
|
make.right.top.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview()
|
||||||
|
make.top.equalTo(coverImageView.snp.bottom).offset(5)
|
||||||
|
make.right.lessThanOrEqualToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview()
|
||||||
|
make.right.lessThanOrEqualToSuperview()
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
182
XSeri/Class/Home/View/XSHomeRankingsCell.swift
Normal file
182
XSeri/Class/Home/View/XSHomeRankingsCell.swift
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
//
|
||||||
|
// XSHomeRankingsCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/31.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomeRankingsCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
var model: XSShortModel? {
|
||||||
|
didSet {
|
||||||
|
coverImageView.xs_setImage(model?.image_url)
|
||||||
|
titleLabel.text = model?.name
|
||||||
|
|
||||||
|
if let category = model?.category?.first, !category.isEmpty {
|
||||||
|
categoryLabel.text = category
|
||||||
|
categoryView.isHidden = false
|
||||||
|
} else {
|
||||||
|
categoryView.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
hotView.setNeedsUpdateConfiguration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var num: Int = 0 {
|
||||||
|
didSet {
|
||||||
|
numLabel.text = "\(num)"
|
||||||
|
|
||||||
|
switch num {
|
||||||
|
case 1:
|
||||||
|
numBgView.image = UIImage(named: "rankings_num_bg_01")
|
||||||
|
numLabel.textColor = ._783902
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
numBgView.image = UIImage(named: "rankings_num_bg_02")
|
||||||
|
numLabel.textColor = .white
|
||||||
|
|
||||||
|
default:
|
||||||
|
numBgView.image = UIImage(named: "rankings_num_bg_03")
|
||||||
|
numLabel.textColor = .white
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var coverImageView: XSImageView = {
|
||||||
|
let imageView = XSImageView()
|
||||||
|
imageView.layer.cornerRadius = 3
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var numBgView: UIImageView = {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var numLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 11, weight: .bold)
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var hotView: UIButton = {
|
||||||
|
var configuration = UIButton.Configuration.plain()
|
||||||
|
configuration.contentInsets = .zero
|
||||||
|
configuration.image = UIImage(named: "hot_icon_01")
|
||||||
|
configuration.imagePadding = 4
|
||||||
|
configuration.imagePlacement = .leading
|
||||||
|
|
||||||
|
let button = UIButton(configuration: configuration)
|
||||||
|
button.isUserInteractionEnabled = false
|
||||||
|
button.configurationUpdateHandler = { [weak self] button in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
let string = NSNumber(value: self.model?.watch_total ?? 0).format()
|
||||||
|
|
||||||
|
button.configuration?.attributedTitle = AttributedString(string, attributes: AttributeContainer([
|
||||||
|
.font : UIFont.font(ofSize: 14, weight: .regular),
|
||||||
|
.foregroundColor : UIColor.white
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
button.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
button.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
return button
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .medium)
|
||||||
|
label.textColor = .white
|
||||||
|
label.numberOfLines = 2
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var categoryView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .white.withAlphaComponent(0.08)
|
||||||
|
view.layer.cornerRadius = 2
|
||||||
|
view.layer.masksToBounds = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var categoryLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 10, weight: .regular)
|
||||||
|
label.textColor = .white
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
contentView.backgroundColor = .white.withAlphaComponent(0.1)
|
||||||
|
contentView.layer.cornerRadius = 6
|
||||||
|
contentView.layer.masksToBounds = true
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomeRankingsCell {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
|
||||||
|
contentView.addSubview(coverImageView)
|
||||||
|
coverImageView.addSubview(numBgView)
|
||||||
|
numBgView.addSubview(numLabel)
|
||||||
|
contentView.addSubview(hotView)
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
contentView.addSubview(categoryView)
|
||||||
|
categoryView.addSubview(categoryLabel)
|
||||||
|
|
||||||
|
coverImageView.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.left.equalToSuperview().offset(12)
|
||||||
|
make.width.equalTo(45)
|
||||||
|
make.height.equalTo(60)
|
||||||
|
}
|
||||||
|
|
||||||
|
numBgView.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(-3)
|
||||||
|
make.left.equalToSuperview().offset(-3)
|
||||||
|
}
|
||||||
|
|
||||||
|
numLabel.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview().offset(-2)
|
||||||
|
make.centerY.equalToSuperview().offset(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
hotView.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.right.equalToSuperview().offset(-12)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(coverImageView.snp.right).offset(19)
|
||||||
|
make.right.lessThanOrEqualTo(hotView.snp.left).offset(-15)
|
||||||
|
make.top.equalToSuperview().offset(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryView.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(titleLabel)
|
||||||
|
make.right.lessThanOrEqualTo(hotView.snp.left).offset(-15)
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(10)
|
||||||
|
make.height.equalTo(18)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.left.equalToSuperview().offset(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
58
XSeri/Class/Home/View/XSHomeSearchButton.swift
Normal file
58
XSeri/Class/Home/View/XSHomeSearchButton.swift
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
//
|
||||||
|
// XSHomeSearchButton.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2025/12/30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSHomeSearchButton: UIControl {
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
return .init(width: XSScreen.width, height: 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var iconImageView = UIImageView(image: UIImage(named: "search_icon_01"))
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .medium)
|
||||||
|
label.textColor = .white.withAlphaComponent(0.6)
|
||||||
|
label.text = "Through the Storm".localized
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = .white.withAlphaComponent(0.1)
|
||||||
|
layer.cornerRadius = 8
|
||||||
|
layer.masksToBounds = true
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSHomeSearchButton {
|
||||||
|
private func xs_setupUI() {
|
||||||
|
addSubview(iconImageView)
|
||||||
|
addSubview(titleLabel)
|
||||||
|
|
||||||
|
iconImageView.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.left.equalToSuperview().offset(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.left.equalTo(iconImageView.snp.right).offset(8)
|
||||||
|
make.right.lessThanOrEqualToSuperview().offset(-12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
XSeri/Class/Home/View/XSSearchGradientButton.swift
Normal file
58
XSeri/Class/Home/View/XSSearchGradientButton.swift
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
//
|
||||||
|
// XSSearchGradientButton.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
/// 搜索按钮(渐变背景)
|
||||||
|
final class XSSearchGradientButton: UIControl {
|
||||||
|
|
||||||
|
private let gradientLayer = CAGradientLayer()
|
||||||
|
|
||||||
|
private lazy var iconImageView: UIImageView = {
|
||||||
|
let imageView = UIImageView(image: UIImage(named: "search_icon_02"))
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
gradientLayer.frame = bounds
|
||||||
|
layer.cornerRadius = bounds.height / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchGradientButton {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
// 渐变用于模拟 Figma 中的金色按钮
|
||||||
|
gradientLayer.colors = [
|
||||||
|
UIColor.F_5_BD_7_E.cgColor,
|
||||||
|
UIColor.FFEABC.cgColor,
|
||||||
|
UIColor.FFCF_99.cgColor
|
||||||
|
]
|
||||||
|
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
|
||||||
|
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
|
||||||
|
layer.insertSublayer(gradientLayer, at: 0)
|
||||||
|
layer.masksToBounds = true
|
||||||
|
|
||||||
|
addSubview(iconImageView)
|
||||||
|
|
||||||
|
iconImageView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
231
XSeri/Class/Home/View/XSSearchHeaderView.swift
Normal file
231
XSeri/Class/Home/View/XSSearchHeaderView.swift
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
//
|
||||||
|
// XSSearchHeaderView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
final class XSSearchHeaderView: UIView {
|
||||||
|
|
||||||
|
enum Style {
|
||||||
|
case home
|
||||||
|
case input
|
||||||
|
}
|
||||||
|
|
||||||
|
var text: String? {
|
||||||
|
get { textField.text }
|
||||||
|
set {
|
||||||
|
textField.text = newValue
|
||||||
|
xs_updateSearchButtonVisibility(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回按钮回调
|
||||||
|
var didTapBack: (() -> Void)?
|
||||||
|
/// 搜索按钮回调
|
||||||
|
var didTapSearch: ((_ keyword: String?) -> Void)?
|
||||||
|
/// 输入开始回调
|
||||||
|
var didBeginEditing: ((_ keyword: String?) -> Void)?
|
||||||
|
/// 输入变化回调
|
||||||
|
var didChangeText: ((_ keyword: String?) -> Void)?
|
||||||
|
|
||||||
|
var style: Style = .home {
|
||||||
|
didSet {
|
||||||
|
xs_applyStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var placeholder: String? {
|
||||||
|
didSet {
|
||||||
|
// 使用富文本占位,控制颜色与透明度
|
||||||
|
let text = placeholder ?? ""
|
||||||
|
textField.attributedPlaceholder = NSAttributedString(
|
||||||
|
string: text,
|
||||||
|
attributes: [
|
||||||
|
.foregroundColor: UIColor.white.withAlphaComponent(0.6),
|
||||||
|
.font: UIFont.font(ofSize: 14, weight: .medium)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var backButton: UIButton = {
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.setImage(UIImage(named: "arrow_left_icon_02"), for: .normal)
|
||||||
|
button.tintColor = .white
|
||||||
|
button.addTarget(self, action: #selector(xs_handleBack), for: .touchUpInside)
|
||||||
|
return button
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var searchContainer: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = UIColor.white.withAlphaComponent(0.18)
|
||||||
|
view.layer.cornerRadius = 19
|
||||||
|
view.layer.masksToBounds = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var searchIconView: UIImageView = {
|
||||||
|
let imageView = UIImageView(image: UIImage(named: "search_icon_01"))
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var textField: UITextField = {
|
||||||
|
let textField = UITextField()
|
||||||
|
textField.textColor = .white
|
||||||
|
textField.font = .font(ofSize: 14, weight: .medium)
|
||||||
|
textField.clearButtonMode = .never
|
||||||
|
textField.returnKeyType = .search
|
||||||
|
textField.delegate = self
|
||||||
|
textField.addTarget(self, action: #selector(xs_textChanged), for: .editingChanged)
|
||||||
|
return textField
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var searchButton = XSSearchGradientButton()
|
||||||
|
private var containerHeightConstraint: Constraint?
|
||||||
|
private var textLeadingToSuperview: Constraint?
|
||||||
|
private var textLeadingToIcon: Constraint?
|
||||||
|
private var textTrailingToButton: Constraint?
|
||||||
|
private var textTrailingToSuperview: Constraint?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func becomeFirstResponder() -> Bool {
|
||||||
|
let result = super.becomeFirstResponder()
|
||||||
|
self.textField.becomeFirstResponder()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchHeaderView {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
addSubview(backButton)
|
||||||
|
addSubview(searchContainer)
|
||||||
|
searchContainer.addSubview(searchIconView)
|
||||||
|
searchContainer.addSubview(textField)
|
||||||
|
searchContainer.addSubview(searchButton)
|
||||||
|
|
||||||
|
searchButton.addTarget(self, action: #selector(xs_handleSearch), for: .touchUpInside)
|
||||||
|
|
||||||
|
backButton.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview()
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(CGSize(width: 24, height: 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
searchContainer.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(backButton.snp.right).offset(8)
|
||||||
|
make.right.equalToSuperview()
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
containerHeightConstraint = make.height.equalTo(38).constraint
|
||||||
|
}
|
||||||
|
|
||||||
|
searchIconView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview().offset(12)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(CGSize(width: 16, height: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
searchButton.snp.makeConstraints { make in
|
||||||
|
make.right.equalToSuperview().offset(-4)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(CGSize(width: 57, height: 30))
|
||||||
|
}
|
||||||
|
|
||||||
|
textField.snp.makeConstraints { make in
|
||||||
|
textLeadingToSuperview = make.left.equalToSuperview().offset(20).constraint
|
||||||
|
textLeadingToIcon = make.left.equalTo(searchIconView.snp.right).offset(8).constraint
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
textTrailingToButton = make.right.equalTo(searchButton.snp.left).offset(-8).constraint
|
||||||
|
textTrailingToSuperview = make.right.equalToSuperview().offset(-12).constraint
|
||||||
|
make.height.equalTo(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
xs_applyStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchHeaderView {
|
||||||
|
|
||||||
|
@objc private func xs_handleBack() {
|
||||||
|
didTapBack?()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func xs_handleSearch() {
|
||||||
|
didTapSearch?(textField.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func xs_textChanged() {
|
||||||
|
let value = textField.text
|
||||||
|
xs_updateSearchButtonVisibility(value)
|
||||||
|
didChangeText?(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchHeaderView: UITextFieldDelegate {
|
||||||
|
|
||||||
|
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||||
|
didBeginEditing?(textField.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
|
didTapSearch?(textField.text)
|
||||||
|
textField.resignFirstResponder()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchHeaderView {
|
||||||
|
|
||||||
|
private func xs_applyStyle() {
|
||||||
|
switch style {
|
||||||
|
case .home:
|
||||||
|
searchIconView.isHidden = true
|
||||||
|
searchContainer.backgroundColor = UIColor.white.withAlphaComponent(0.18)
|
||||||
|
searchContainer.layer.cornerRadius = 19
|
||||||
|
containerHeightConstraint?.update(offset: 38)
|
||||||
|
textLeadingToSuperview?.activate()
|
||||||
|
textLeadingToIcon?.deactivate()
|
||||||
|
case .input:
|
||||||
|
searchIconView.isHidden = false
|
||||||
|
searchContainer.backgroundColor = UIColor.white.withAlphaComponent(0.1)
|
||||||
|
searchContainer.layer.cornerRadius = 18
|
||||||
|
containerHeightConstraint?.update(offset: 36)
|
||||||
|
textLeadingToSuperview?.deactivate()
|
||||||
|
textLeadingToIcon?.activate()
|
||||||
|
}
|
||||||
|
xs_updateSearchButtonVisibility(textField.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xs_updateSearchButtonVisibility(_ text: String?) {
|
||||||
|
let keyword = (text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let shouldShow: Bool
|
||||||
|
switch style {
|
||||||
|
case .home:
|
||||||
|
shouldShow = true
|
||||||
|
case .input:
|
||||||
|
shouldShow = !keyword.isEmpty
|
||||||
|
}
|
||||||
|
searchButton.isHidden = !shouldShow
|
||||||
|
if shouldShow {
|
||||||
|
textTrailingToSuperview?.deactivate()
|
||||||
|
textTrailingToButton?.activate()
|
||||||
|
} else {
|
||||||
|
textTrailingToButton?.deactivate()
|
||||||
|
textTrailingToSuperview?.activate()
|
||||||
|
}
|
||||||
|
layoutIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
110
XSeri/Class/Home/View/XSSearchHistoryHotView.swift
Normal file
110
XSeri/Class/Home/View/XSSearchHistoryHotView.swift
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
//
|
||||||
|
// XSSearchHistoryHotView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
/// 搜索历史 + 热门榜单容器视图
|
||||||
|
final class XSSearchHistoryHotView: UIView {
|
||||||
|
|
||||||
|
/// 是否有可展示内容
|
||||||
|
var hasContent: Bool {
|
||||||
|
return !historyTags.isEmpty || !hotSections.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空历史回调
|
||||||
|
var didTapClear: (() -> Void)? {
|
||||||
|
didSet {
|
||||||
|
recentView.didTapClear = didTapClear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// 点击历史标签回调
|
||||||
|
var didSelectHistory: ((_ text: String) -> Void)? {
|
||||||
|
didSet {
|
||||||
|
recentView.didSelectTag = didSelectHistory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var historyTitle: String? {
|
||||||
|
didSet {
|
||||||
|
recentView.title = historyTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var historyTags: [String] = [] {
|
||||||
|
didSet {
|
||||||
|
recentView.tags = historyTags
|
||||||
|
xs_updateVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hotTitle: String? {
|
||||||
|
didSet {
|
||||||
|
hotSectionView.title = hotTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点击热门榜单条目回调
|
||||||
|
var didSelectHot: ((_ model: XSShortModel) -> Void)? {
|
||||||
|
didSet {
|
||||||
|
hotSectionView.didSelectItem = didSelectHot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hotSections: [XSSearchHotSection] = [] {
|
||||||
|
didSet {
|
||||||
|
hotSectionView.sections = hotSections
|
||||||
|
xs_updateVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let recentContainer = UIView()
|
||||||
|
private let recentView = XSSearchRecentView()
|
||||||
|
private let hotSectionView = XSSearchHotSectionView()
|
||||||
|
|
||||||
|
private let stackView: UIStackView = {
|
||||||
|
let view = UIStackView()
|
||||||
|
view.axis = .vertical
|
||||||
|
view.spacing = 24
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchHistoryHotView {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
addSubview(stackView)
|
||||||
|
recentContainer.addSubview(recentView)
|
||||||
|
|
||||||
|
stackView.addArrangedSubview(recentContainer)
|
||||||
|
stackView.addArrangedSubview(hotSectionView)
|
||||||
|
|
||||||
|
stackView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
recentView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
xs_updateVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xs_updateVisibility() {
|
||||||
|
recentContainer.isHidden = historyTags.isEmpty
|
||||||
|
hotSectionView.isHidden = hotSections.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
108
XSeri/Class/Home/View/XSSearchHotListCardView.swift
Normal file
108
XSeri/Class/Home/View/XSSearchHotListCardView.swift
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
//
|
||||||
|
// XSSearchHotListCardView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
final class XSSearchHotListCardView: UIView {
|
||||||
|
|
||||||
|
/// 榜单卡片数据
|
||||||
|
var section: XSSearchHotSection? {
|
||||||
|
didSet {
|
||||||
|
xs_applySection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点击榜单条目回调
|
||||||
|
var didSelectItem: ((_ model: XSShortModel) -> Void)?
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .bold)
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var iconImageView: UIImageView = {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var stackView: UIStackView = {
|
||||||
|
let view = UIStackView()
|
||||||
|
view.axis = .vertical
|
||||||
|
view.spacing = 8
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchHotListCardView {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
layer.cornerRadius = 12
|
||||||
|
layer.masksToBounds = true
|
||||||
|
layer.borderWidth = 1
|
||||||
|
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(iconImageView)
|
||||||
|
addSubview(stackView)
|
||||||
|
|
||||||
|
iconImageView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview().offset(16)
|
||||||
|
make.top.equalToSuperview().offset(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(iconImageView)
|
||||||
|
make.left.equalTo(iconImageView.snp.right).offset(2)
|
||||||
|
make.right.lessThanOrEqualToSuperview().offset(-10)
|
||||||
|
}
|
||||||
|
|
||||||
|
stackView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview().offset(16)
|
||||||
|
make.right.equalToSuperview().offset(-16)
|
||||||
|
make.top.equalToSuperview().offset(53)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xs_applySection() {
|
||||||
|
guard let section = section else { return }
|
||||||
|
titleLabel.text = section.title
|
||||||
|
iconImageView.image = section.icon
|
||||||
|
|
||||||
|
switch section.style {
|
||||||
|
case .gold:
|
||||||
|
layer.borderColor = UIColor.FDE_7_B_8.withAlphaComponent(0.4).cgColor
|
||||||
|
titleLabel.textColor = .FDE_7_B_8
|
||||||
|
case .purple:
|
||||||
|
layer.borderColor = UIColor.E_0_C_6_FF.withAlphaComponent(0.2).cgColor
|
||||||
|
titleLabel.textColor = UIColor.E_0_C_6_FF.withAlphaComponent(0.7)
|
||||||
|
}
|
||||||
|
|
||||||
|
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
section.items.enumerated().forEach { index, item in
|
||||||
|
let itemView = XSSearchHotListItemView()
|
||||||
|
itemView.configure(model: item, rank: index + 1, style: section.style)
|
||||||
|
itemView.didTap = { [weak self] model in
|
||||||
|
self?.didSelectItem?(model)
|
||||||
|
}
|
||||||
|
itemView.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(65)
|
||||||
|
}
|
||||||
|
stackView.addArrangedSubview(itemView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
147
XSeri/Class/Home/View/XSSearchHotListItemView.swift
Normal file
147
XSeri/Class/Home/View/XSSearchHotListItemView.swift
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
//
|
||||||
|
// XSSearchHotListItemView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
final class XSSearchHotListItemView: UIView {
|
||||||
|
|
||||||
|
/// 点击条目回调
|
||||||
|
var didTap: ((_ model: XSShortModel) -> Void)?
|
||||||
|
|
||||||
|
private var model: XSShortModel?
|
||||||
|
|
||||||
|
private lazy var rankBadgeView: XSView = {
|
||||||
|
let view = XSView()
|
||||||
|
view.xs_startPoint = .init(x: 0.5, y: 0)
|
||||||
|
view.xs_endPoint = .init(x: 0.5, y: 1)
|
||||||
|
view.layer.masksToBounds = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var rankLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 10, weight: .bold)
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var coverImageView: XSImageView = {
|
||||||
|
let imageView = XSImageView()
|
||||||
|
imageView.layer.cornerRadius = 4
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 10, weight: .semibold)
|
||||||
|
label.textColor = UIColor.white.withAlphaComponent(0.9)
|
||||||
|
label.numberOfLines = 2
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var categoryLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 10, weight: .medium)
|
||||||
|
label.textColor = UIColor.white.withAlphaComponent(0.6)
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
rankBadgeView.layer.cornerRadius = rankBadgeView.bounds.height / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(model: XSShortModel, rank: Int, style: XSSearchHotSectionStyle) {
|
||||||
|
// 根据榜单样式调整数字徽标的颜色
|
||||||
|
self.model = model
|
||||||
|
rankLabel.text = "\(rank)"
|
||||||
|
titleLabel.text = model.name ?? model.xs_description
|
||||||
|
let category = model.category?.first ?? model.categoryList?.first?.name ?? ""
|
||||||
|
categoryLabel.text = category
|
||||||
|
categoryLabel.isHidden = category.isEmpty
|
||||||
|
coverImageView.xs_setImage(model.image_url)
|
||||||
|
|
||||||
|
switch style {
|
||||||
|
case .gold:
|
||||||
|
if rank <= 3 {
|
||||||
|
rankBadgeView.xs_colors = [UIColor.FFE_6_B_3.cgColor, UIColor.F_4_C_783.cgColor]
|
||||||
|
rankLabel.textColor = ._060606
|
||||||
|
} else {
|
||||||
|
rankBadgeView.xs_colors = [UIColor.clear.cgColor, UIColor.clear.cgColor]
|
||||||
|
rankLabel.textColor = .FCD_68_D
|
||||||
|
}
|
||||||
|
case .purple:
|
||||||
|
if rank <= 3 {
|
||||||
|
rankBadgeView.xs_colors = [UIColor.E_7_CCFF.cgColor, UIColor.D_7_BCFF.cgColor, UIColor.A_191_FF.cgColor]
|
||||||
|
rankLabel.textColor = .black
|
||||||
|
} else {
|
||||||
|
rankBadgeView.xs_colors = [UIColor.clear.cgColor, UIColor.clear.cgColor]
|
||||||
|
rankLabel.textColor = UIColor.B_4_A_0_FF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchHotListItemView {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
let tap = UITapGestureRecognizer(target: self, action: #selector(xs_handleTap))
|
||||||
|
addGestureRecognizer(tap)
|
||||||
|
isUserInteractionEnabled = true
|
||||||
|
|
||||||
|
addSubview(rankBadgeView)
|
||||||
|
rankBadgeView.addSubview(rankLabel)
|
||||||
|
addSubview(coverImageView)
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(categoryLabel)
|
||||||
|
|
||||||
|
rankBadgeView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview()
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(CGSize(width: 14, height: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
rankLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
coverImageView.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(rankBadgeView.snp.right).offset(8)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(CGSize(width: 48, height: 65))
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(coverImageView.snp.right).offset(10)
|
||||||
|
make.top.equalToSuperview()
|
||||||
|
make.right.lessThanOrEqualToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(titleLabel)
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(6)
|
||||||
|
make.right.lessThanOrEqualToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchHotListItemView {
|
||||||
|
|
||||||
|
@objc private func xs_handleTap() {
|
||||||
|
guard let model = model else { return }
|
||||||
|
didTap?(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
115
XSeri/Class/Home/View/XSSearchHotSectionView.swift
Normal file
115
XSeri/Class/Home/View/XSSearchHotSectionView.swift
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
//
|
||||||
|
// XSSearchHotSectionView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
final class XSSearchHotSectionView: UIView {
|
||||||
|
|
||||||
|
var title: String? {
|
||||||
|
didSet {
|
||||||
|
titleLabel.text = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 榜单板块数据
|
||||||
|
var sections: [XSSearchHotSection] = [] {
|
||||||
|
didSet {
|
||||||
|
xs_reloadSections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点击榜单条目回调
|
||||||
|
var didSelectItem: ((_ model: XSShortModel) -> Void)?
|
||||||
|
|
||||||
|
private var cardViews: [XSSearchHotListCardView] = []
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .bold)
|
||||||
|
label.textColor = .white
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var scrollView: UIScrollView = {
|
||||||
|
let view = UIScrollView()
|
||||||
|
view.showsHorizontalScrollIndicator = false
|
||||||
|
view.keyboardDismissMode = .onDrag
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let contentView = UIView()
|
||||||
|
|
||||||
|
private lazy var stackView: UIStackView = {
|
||||||
|
let view = UIStackView()
|
||||||
|
view.axis = .horizontal
|
||||||
|
view.spacing = 22
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchHotSectionView {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(scrollView)
|
||||||
|
scrollView.addSubview(contentView)
|
||||||
|
contentView.addSubview(stackView)
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview().offset(16)
|
||||||
|
make.right.equalToSuperview().offset(-16)
|
||||||
|
make.top.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
||||||
|
make.left.right.bottom.equalToSuperview()
|
||||||
|
make.height.equalTo(435)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
make.height.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
stackView.snp.makeConstraints { make in
|
||||||
|
make.top.bottom.equalToSuperview()
|
||||||
|
make.left.equalToSuperview().offset(16)
|
||||||
|
make.right.equalToSuperview().offset(-16)
|
||||||
|
make.height.equalTo(435)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xs_reloadSections() {
|
||||||
|
cardViews.forEach { $0.removeFromSuperview() }
|
||||||
|
cardViews = sections.map { _ in XSSearchHotListCardView() }
|
||||||
|
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
|
||||||
|
for (index, section) in sections.enumerated() {
|
||||||
|
let cardView = cardViews[index]
|
||||||
|
cardView.section = section
|
||||||
|
cardView.didSelectItem = { [weak self] model in
|
||||||
|
self?.didSelectItem?(model)
|
||||||
|
}
|
||||||
|
stackView.addArrangedSubview(cardView)
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.width.equalTo(237)
|
||||||
|
make.height.equalTo(435)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
XSeri/Class/Home/View/XSSearchRecentView.swift
Normal file
101
XSeri/Class/Home/View/XSSearchRecentView.swift
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
//
|
||||||
|
// XSSearchRecentView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
final class XSSearchRecentView: UIView {
|
||||||
|
|
||||||
|
/// 清空历史回调
|
||||||
|
var didTapClear: (() -> Void)?
|
||||||
|
/// 点击历史标签回调
|
||||||
|
var didSelectTag: ((_ text: String) -> Void)? {
|
||||||
|
didSet {
|
||||||
|
tagsView.didSelectTag = didSelectTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String? {
|
||||||
|
didSet {
|
||||||
|
titleLabel.text = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 最近搜索标签数据
|
||||||
|
var tags: [String] = [] {
|
||||||
|
didSet {
|
||||||
|
tagsView.tags = tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tagsHeightConstraint: Constraint?
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .bold)
|
||||||
|
label.textColor = .white
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var clearButton: UIButton = {
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.setImage(UIImage(named: "delete_icon_01"), for: .normal)
|
||||||
|
button.tintColor = .white.withAlphaComponent(0.6)
|
||||||
|
button.addTarget(self, action: #selector(xs_handleClear), for: .touchUpInside)
|
||||||
|
return button
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var tagsView: XSSearchTagsView = {
|
||||||
|
let view = XSSearchTagsView()
|
||||||
|
view.didChangeHeight = { [weak self] height in
|
||||||
|
self?.tagsHeightConstraint?.update(offset: height)
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchRecentView {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(clearButton)
|
||||||
|
addSubview(tagsView)
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.left.top.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearButton.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(titleLabel)
|
||||||
|
make.right.equalToSuperview()
|
||||||
|
make.size.equalTo(CGSize(width: 20, height: 20))
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsView.snp.makeConstraints { make in
|
||||||
|
make.left.right.equalToSuperview()
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
||||||
|
tagsHeightConstraint = make.height.equalTo(0).constraint
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchRecentView {
|
||||||
|
|
||||||
|
@objc private func xs_handleClear() {
|
||||||
|
didTapClear?()
|
||||||
|
}
|
||||||
|
}
|
||||||
63
XSeri/Class/Home/View/XSSearchResultCell.swift
Normal file
63
XSeri/Class/Home/View/XSSearchResultCell.swift
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
//
|
||||||
|
// XSSearchResultCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
final class XSSearchResultCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
private lazy var coverImageView: XSImageView = {
|
||||||
|
let imageView = XSImageView()
|
||||||
|
imageView.placeholderColor = ._1_F_0_B_00
|
||||||
|
imageView.layer.cornerRadius = 8
|
||||||
|
imageView.layer.borderWidth = 1
|
||||||
|
imageView.layer.borderColor = UIColor.white.withAlphaComponent(0.25).cgColor
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 12, weight: .bold)
|
||||||
|
label.textColor = .white
|
||||||
|
label.numberOfLines = 2
|
||||||
|
label.lineBreakMode = .byTruncatingTail
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(model: XSShortModel) {
|
||||||
|
coverImageView.xs_setImage(model.image_url)
|
||||||
|
titleLabel.text = model.name ?? model.xs_description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchResultCell {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
contentView.addSubview(coverImageView)
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
|
||||||
|
coverImageView.snp.makeConstraints { make in
|
||||||
|
make.left.top.right.equalToSuperview()
|
||||||
|
make.height.equalTo(146)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.left.right.equalToSuperview()
|
||||||
|
make.top.equalTo(coverImageView.snp.bottom).offset(8)
|
||||||
|
make.bottom.lessThanOrEqualToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
XSeri/Class/Home/View/XSSearchSuggestionCell.swift
Normal file
146
XSeri/Class/Home/View/XSSearchSuggestionCell.swift
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
//
|
||||||
|
// XSSearchSuggestionCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
final class XSSearchSuggestionCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
private lazy var coverImageView: XSImageView = {
|
||||||
|
let imageView = XSImageView()
|
||||||
|
imageView.placeholderColor = ._1_F_0_B_00
|
||||||
|
imageView.layer.cornerRadius = 7
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .semibold)
|
||||||
|
label.textColor = UIColor.white.withAlphaComponent(0.4)
|
||||||
|
label.numberOfLines = 1
|
||||||
|
label.lineBreakMode = .byTruncatingTail
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var tagContainer: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = UIColor.white.withAlphaComponent(0.1)
|
||||||
|
view.layer.cornerRadius = 6
|
||||||
|
view.layer.masksToBounds = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var tagLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .regular)
|
||||||
|
label.textColor = .white
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var epLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .regular)
|
||||||
|
label.textColor = .white
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var infoStack: UIStackView = {
|
||||||
|
let stackView = UIStackView()
|
||||||
|
stackView.axis = .vertical
|
||||||
|
stackView.spacing = 23
|
||||||
|
stackView.alignment = .leading
|
||||||
|
return stackView
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(model: XSShortModel, keyword: String) {
|
||||||
|
coverImageView.xs_setImage(model.image_url)
|
||||||
|
let title = model.name ?? model.xs_description ?? ""
|
||||||
|
titleLabel.attributedText = xs_highlight(text: title, keyword: keyword)
|
||||||
|
let category = model.category?.first ?? model.categoryList?.first?.name ?? ""
|
||||||
|
tagLabel.text = category
|
||||||
|
tagContainer.isHidden = category.isEmpty
|
||||||
|
if let current = model.current_episode, !current.isEmpty {
|
||||||
|
epLabel.text = "EP.##".localizedReplace(text: current)
|
||||||
|
} else if let total = model.episode_total {
|
||||||
|
epLabel.text = "EP.##".localizedReplace(text: "\(total)")
|
||||||
|
} else {
|
||||||
|
epLabel.text = ""
|
||||||
|
}
|
||||||
|
epLabel.isHidden = (epLabel.text ?? "").isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchSuggestionCell {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
contentView.addSubview(coverImageView)
|
||||||
|
contentView.addSubview(infoStack)
|
||||||
|
tagContainer.addSubview(tagLabel)
|
||||||
|
|
||||||
|
infoStack.addArrangedSubview(titleLabel)
|
||||||
|
infoStack.addArrangedSubview(tagContainer)
|
||||||
|
infoStack.addArrangedSubview(epLabel)
|
||||||
|
|
||||||
|
coverImageView.snp.makeConstraints { make in
|
||||||
|
make.left.top.bottom.equalToSuperview()
|
||||||
|
make.size.equalTo(CGSize(width: 79, height: 104))
|
||||||
|
}
|
||||||
|
|
||||||
|
infoStack.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(coverImageView.snp.right).offset(12)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.right.lessThanOrEqualToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.width.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
tagContainer.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagLabel.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.left.equalToSuperview().offset(12)
|
||||||
|
make.right.equalToSuperview().offset(-12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func xs_highlight(text: String, keyword: String) -> NSAttributedString {
|
||||||
|
let baseColor = UIColor.white.withAlphaComponent(0.4)
|
||||||
|
let highlightColor = UIColor.white
|
||||||
|
let attributed = NSMutableAttributedString(
|
||||||
|
string: text,
|
||||||
|
attributes: [
|
||||||
|
.foregroundColor: baseColor
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
guard !keyword.isEmpty else { return attributed }
|
||||||
|
let lowerText = text.lowercased()
|
||||||
|
let lowerKeyword = keyword.lowercased()
|
||||||
|
var searchRange = lowerText.startIndex..<lowerText.endIndex
|
||||||
|
|
||||||
|
// 高亮关键词
|
||||||
|
while let range = lowerText.range(of: lowerKeyword, options: [], range: searchRange) {
|
||||||
|
let nsRange = NSRange(range, in: text)
|
||||||
|
attributed.addAttribute(.foregroundColor, value: highlightColor, range: nsRange)
|
||||||
|
searchRange = range.upperBound..<lowerText.endIndex
|
||||||
|
}
|
||||||
|
return attributed
|
||||||
|
}
|
||||||
|
}
|
||||||
54
XSeri/Class/Home/View/XSSearchTagCell.swift
Normal file
54
XSeri/Class/Home/View/XSSearchTagCell.swift
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
//
|
||||||
|
// XSSearchTagCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
final class XSSearchTagCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
static let textFont = UIFont.font(ofSize: 12, weight: .bold)
|
||||||
|
|
||||||
|
var title: String? {
|
||||||
|
didSet {
|
||||||
|
titleLabel.text = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = Self.textFont
|
||||||
|
label.textColor = .white
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
contentView.backgroundColor = UIColor.white.withAlphaComponent(0.18)
|
||||||
|
contentView.layer.masksToBounds = true
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
contentView.layer.cornerRadius = contentView.bounds.height / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchTagCell {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
110
XSeri/Class/Home/View/XSSearchTagsView.swift
Normal file
110
XSeri/Class/Home/View/XSSearchTagsView.swift
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
//
|
||||||
|
// XSSearchTagsView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
import collection_view_layouts
|
||||||
|
|
||||||
|
final class XSSearchTagsView: UIView {
|
||||||
|
|
||||||
|
var didChangeHeight: ((_ height: CGFloat) -> Void)?
|
||||||
|
/// 标签点击回调
|
||||||
|
var didSelectTag: ((_ text: String) -> Void)?
|
||||||
|
|
||||||
|
private var lastLayoutWidth: CGFloat = 0
|
||||||
|
|
||||||
|
/// 标签数据列表
|
||||||
|
var tags: [String] = [] {
|
||||||
|
didSet {
|
||||||
|
collectionView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var layout: TagsLayout = {
|
||||||
|
let layout = TagsLayout()
|
||||||
|
layout.delegate = self
|
||||||
|
layout.cellsPadding = .init(horizontal: 12, vertical: 12)
|
||||||
|
layout.contentPadding = .init(horizontal: 0, vertical: 0)
|
||||||
|
return layout
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var collectionView: XSCollectionView = {
|
||||||
|
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
|
collectionView.isScrollEnabled = false
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.dataSource = self
|
||||||
|
collectionView.addObserver(self, forKeyPath: "contentSize", context: nil)
|
||||||
|
collectionView.register(XSSearchTagCell.self, forCellWithReuseIdentifier: "cell")
|
||||||
|
return collectionView
|
||||||
|
}()
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
collectionView.removeObserver(self, forKeyPath: "contentSize")
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||||
|
guard keyPath == "contentSize" else { return }
|
||||||
|
didChangeHeight?(collectionView.contentSize.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
// 宽度确定后刷新布局,避免标签被纵向堆叠
|
||||||
|
if bounds.width != lastLayoutWidth {
|
||||||
|
lastLayoutWidth = bounds.width
|
||||||
|
collectionView.collectionViewLayout.invalidateLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSSearchTagsView {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
addSubview(collectionView)
|
||||||
|
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UICollectionViewDelegate UICollectionViewDataSource
|
||||||
|
extension XSSearchTagsView: UICollectionViewDelegate, UICollectionViewDataSource {
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
return tags.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! XSSearchTagCell
|
||||||
|
cell.title = tags[indexPath.row]
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
didSelectTag?(tags[indexPath.row])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - LayoutDelegate
|
||||||
|
extension XSSearchTagsView: LayoutDelegate {
|
||||||
|
|
||||||
|
func cellSize(indexPath: IndexPath) -> CGSize {
|
||||||
|
let text = tags[indexPath.row]
|
||||||
|
let width = text.size(XSSearchTagCell.textFont, .init(width: XSScreen.width, height: 24)).width
|
||||||
|
return .init(width: width + 20, height: 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
XSeri/Class/Home/ViewModel/XSHomeViewModel.swift
Normal file
41
XSeri/Class/Home/ViewModel/XSHomeViewModel.swift
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// XSHomeViewModel.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/1/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class XSHomeViewModel: NSObject {
|
||||||
|
|
||||||
|
var dataArr: [XSHomeModuleItem] = []
|
||||||
|
|
||||||
|
|
||||||
|
func requestHomeData() async {
|
||||||
|
guard let list = await XSHomeAPI.requestHomeData() else { return }
|
||||||
|
self.dataArr.removeAll()
|
||||||
|
|
||||||
|
var popularItem: XSHomeModuleItem?
|
||||||
|
var rankingsItem: XSHomeModuleItem?
|
||||||
|
|
||||||
|
list.forEach {
|
||||||
|
if $0.module_key == .week_highest_recommend {
|
||||||
|
popularItem = $0
|
||||||
|
} else if $0.module_key == .week_ranking {
|
||||||
|
rankingsItem = $0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let item = popularItem {
|
||||||
|
self.dataArr.append(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let item = rankingsItem {
|
||||||
|
self.dataArr.append(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
94
XSeri/Class/Mine/Controller/XSAboutViewController.swift
Normal file
94
XSeri/Class/Mine/Controller/XSAboutViewController.swift
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
//
|
||||||
|
// XSAboutViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSAboutViewController: XSCommonViewController {
|
||||||
|
|
||||||
|
private lazy var dataArr: [XSMineItem] = {
|
||||||
|
let arr = [
|
||||||
|
XSMineItem(type: .web, title: "Privacy Policy".localized, url: kXSPrivacyPolicyWebUrl),
|
||||||
|
XSMineItem(type: .web, title: "User Agreement".localized, url: kXSUserAgreementWebUrl),
|
||||||
|
XSMineItem(type: .safari, title: "Visit Website".localized, url: XSWebBaseURL),
|
||||||
|
]
|
||||||
|
return arr
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var tableView: XSTableView = {
|
||||||
|
let tableView = XSTableView(frame: .zero, style: .grouped)
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.dataSource = self
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.register(XSAboutCell.self, forCellReuseIdentifier: "cell")
|
||||||
|
tableView.register(XSAboutHeaderView.self, forHeaderFooterViewReuseIdentifier: "header")
|
||||||
|
return tableView
|
||||||
|
}()
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
self.title = "About".localized
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
self.navigationController?.setNavigationBarHidden(false, animated: true)
|
||||||
|
self.xs_setNavigationStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSAboutViewController {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
view.addSubview(tableView)
|
||||||
|
|
||||||
|
tableView.snp.makeConstraints { make in
|
||||||
|
make.left.right.bottom.equalToSuperview()
|
||||||
|
make.top.equalTo(self.view.safeAreaLayoutGuide)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//MARK: UITableViewDelegate UITableViewDataSource
|
||||||
|
extension XSAboutViewController: UITableViewDelegate, UITableViewDataSource {
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! XSAboutCell
|
||||||
|
cell.item = self.dataArr[indexPath.row]
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
return self.dataArr.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||||
|
let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: "header") as? XSAboutHeaderView
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
let item = self.dataArr[indexPath.row]
|
||||||
|
guard let urlStr = item.url else { return }
|
||||||
|
|
||||||
|
if item.type == .web {
|
||||||
|
let vc = XSBaseWebViewController()
|
||||||
|
vc.webUrl = urlStr
|
||||||
|
self.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
} else {
|
||||||
|
if let url = URL(string: urlStr) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
97
XSeri/Class/Mine/Controller/XSFeedbackViewController.swift
Normal file
97
XSeri/Class/Mine/Controller/XSFeedbackViewController.swift
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// XSFeedbackViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/28.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSFeedbackViewController: XSAppWebViewController {
|
||||||
|
|
||||||
|
|
||||||
|
private lazy var rightButton: UIButton = {
|
||||||
|
let button = UIButton(type: .custom)
|
||||||
|
button.setImage(UIImage(named: "feedback_icon_02"), for: .normal)
|
||||||
|
button.addTarget(self, action: #selector(handleRightBarButton), for: .touchUpInside)
|
||||||
|
return button
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var redView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .FF_313_B
|
||||||
|
view.layer.cornerRadius = 8
|
||||||
|
view.isHidden = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var redLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 12, weight: .bold)
|
||||||
|
label.textColor = .white
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
self.webUrl = kXSFeedBackHomeWebUrl
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: rightButton)
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
Task {
|
||||||
|
await self.requestRedCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleRightBarButton() {
|
||||||
|
let vc = XSAppWebViewController()
|
||||||
|
vc.webUrl = kXSFeedBackListWebUrl
|
||||||
|
self.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSFeedbackViewController {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
|
||||||
|
rightButton.addSubview(redView)
|
||||||
|
redView.addSubview(redLabel)
|
||||||
|
|
||||||
|
redView.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(16)
|
||||||
|
make.width.greaterThanOrEqualTo(16)
|
||||||
|
make.top.equalToSuperview().offset(-8)
|
||||||
|
make.right.equalToSuperview().offset(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
redLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.left.greaterThanOrEqualToSuperview().offset(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSFeedbackViewController {
|
||||||
|
|
||||||
|
private func requestRedCount() async {
|
||||||
|
guard let model = await XSSettingAPI.requestFeedbackRedCount() else { return }
|
||||||
|
|
||||||
|
if let count = model.feedback_notice_num, count > 0 {
|
||||||
|
self.redView.isHidden = false
|
||||||
|
self.redLabel.text = "\(count)"
|
||||||
|
} else {
|
||||||
|
self.redView.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
161
XSeri/Class/Mine/Controller/XSMineViewController.swift
Normal file
161
XSeri/Class/Mine/Controller/XSMineViewController.swift
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
//
|
||||||
|
// XSMineViewController.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSMineViewController: XSCommonViewController {
|
||||||
|
|
||||||
|
private lazy var dataArr: [XSMineItem] = []
|
||||||
|
|
||||||
|
private lazy var historyDataArr: [XSShortModel] = []
|
||||||
|
|
||||||
|
private lazy var logoImageView: UIImageView = {
|
||||||
|
let imageView = UIImageView(image: UIImage(named: "logo_icon_01"))
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var tableView: XSTableView = {
|
||||||
|
let tableView = XSTableView(frame: .zero, style: .grouped)
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.dataSource = self
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.rowHeight = 44
|
||||||
|
tableView.xs_addRefreshHeader { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.handleHeaderRefresh(nil)
|
||||||
|
}
|
||||||
|
tableView.register(XSMineCell.self, forCellReuseIdentifier: "cell")
|
||||||
|
tableView.register(XSMineHeaderView.self, forHeaderFooterViewReuseIdentifier: "header")
|
||||||
|
return tableView
|
||||||
|
}()
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(networkStatusDidChangeNotification), name: XSNetworkMonitorManager.networkStatusDidChangeNotification, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(userInfoUpdateNotification), name: XSLoginManager.userInfoUpdateNotification, object: nil)
|
||||||
|
|
||||||
|
self.createDataArr()
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
self.navigationController?.setNavigationBarHidden(true, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
self.createDataArr()
|
||||||
|
Task {
|
||||||
|
await XSLoginManager.manager.updateUserInfo()
|
||||||
|
|
||||||
|
await self.requestPlayHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func handleHeaderRefresh(_ completer: (() -> Void)?) {
|
||||||
|
Task {
|
||||||
|
await XSLoginManager.manager.updateUserInfo()
|
||||||
|
await self.requestPlayHistory()
|
||||||
|
self.tableView.xs_endHeaderRefreshing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func userInfoUpdateNotification() {
|
||||||
|
self.tableView.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func networkStatusDidChangeNotification() {
|
||||||
|
Task {
|
||||||
|
await XSLoginManager.manager.updateUserInfo()
|
||||||
|
await self.requestPlayHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSMineViewController {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
view.addSubview(logoImageView)
|
||||||
|
view.addSubview(self.tableView)
|
||||||
|
|
||||||
|
logoImageView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview().offset(16)
|
||||||
|
make.top.equalTo(self.view.safeAreaLayoutGuide).offset(9)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tableView.snp.makeConstraints { make in
|
||||||
|
make.left.right.bottom.equalToSuperview()
|
||||||
|
make.top.equalTo(logoImageView.snp.bottom).offset(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: UITableViewDelegate UITableViewDataSource
|
||||||
|
extension XSMineViewController: UITableViewDelegate, UITableViewDataSource {
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! XSMineCell
|
||||||
|
cell.item = self.dataArr[indexPath.row]
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
return self.dataArr.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||||
|
let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: "header") as? XSMineHeaderView
|
||||||
|
view?.userInfo = XSLoginManager.manager.userInfo
|
||||||
|
view?.historyDataArr = self.historyDataArr
|
||||||
|
view?.updateLayout()
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
let item = self.dataArr[indexPath.row]
|
||||||
|
switch item.type {
|
||||||
|
case .about:
|
||||||
|
let vc = XSAboutViewController()
|
||||||
|
self.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
|
||||||
|
case .feedback:
|
||||||
|
let vc = XSFeedbackViewController()
|
||||||
|
self.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSMineViewController {
|
||||||
|
|
||||||
|
private func createDataArr() {
|
||||||
|
let arr = [
|
||||||
|
// XSMineItem(type: .setting, iconImage: UIImage(named: "setting_icon_01"), title: "Setting".localized),
|
||||||
|
XSMineItem(type: .about, iconImage: UIImage(named: "about_icon_01"), title: "About".localized),
|
||||||
|
XSMineItem(type: .feedback, iconImage: UIImage(named: "feedback_icon_01"), title: "Feedback".localized)
|
||||||
|
]
|
||||||
|
self.dataArr = arr
|
||||||
|
self.tableView.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestPlayHistory() async {
|
||||||
|
guard let list = await XSVideoAPI.requestPlayHistorys(page: 1) else { return }
|
||||||
|
self.historyDataArr = list
|
||||||
|
self.tableView.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
13
XSeri/Class/Mine/Model/XSFeedbackCountModel.swift
Normal file
13
XSeri/Class/Mine/Model/XSFeedbackCountModel.swift
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
//
|
||||||
|
// XSFeedbackCountModel.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/28.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SmartCodable
|
||||||
|
|
||||||
|
struct XSFeedbackCountModel: SmartCodable {
|
||||||
|
var feedback_notice_num: Int?
|
||||||
|
}
|
||||||
27
XSeri/Class/Mine/Model/XSMineItem.swift
Normal file
27
XSeri/Class/Mine/Model/XSMineItem.swift
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
//
|
||||||
|
// XSMineItem.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct XSMineItem {
|
||||||
|
|
||||||
|
enum ItemType {
|
||||||
|
case setting
|
||||||
|
case about
|
||||||
|
case feedback
|
||||||
|
case web
|
||||||
|
case safari
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var type: ItemType?
|
||||||
|
var iconImage: UIImage?
|
||||||
|
var title: String?
|
||||||
|
var url: String?
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
62
XSeri/Class/Mine/View/XSAboutCell.swift
Normal file
62
XSeri/Class/Mine/View/XSAboutCell.swift
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
//
|
||||||
|
// XSAboutCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSAboutCell: XSTableViewCell {
|
||||||
|
|
||||||
|
var item: XSMineItem? {
|
||||||
|
didSet {
|
||||||
|
titleLabel.text = item?.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 12, weight: .semibold)
|
||||||
|
label.textColor = .white
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var lineView: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = .white.withAlphaComponent(0.1)
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
xs_indicatorImageView.image = UIImage(named: "arrow_right_icon_02")
|
||||||
|
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
contentView.addSubview(xs_indicatorImageView)
|
||||||
|
contentView.addSubview(lineView)
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.left.equalToSuperview().offset(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
xs_indicatorImageView.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.right.equalToSuperview().offset(-30)
|
||||||
|
}
|
||||||
|
|
||||||
|
lineView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview().offset(30)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
|
make.height.equalTo(0.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
64
XSeri/Class/Mine/View/XSAboutHeaderView.swift
Normal file
64
XSeri/Class/Mine/View/XSAboutHeaderView.swift
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
//
|
||||||
|
// XSAboutHeaderView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSAboutHeaderView: UITableViewHeaderFooterView {
|
||||||
|
|
||||||
|
private lazy var logoImageView: UIImageView = {
|
||||||
|
let imageView = UIImageView(image: UIImage(named: "logo_icon_02"))
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var appNameLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 18, weight: .semibold)
|
||||||
|
label.textColor = .white
|
||||||
|
label.text = kXSName
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var versionLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 12, weight: .regular)
|
||||||
|
label.textColor = .FDE_7_B_8
|
||||||
|
label.text = "Version " + kXSVersion
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(reuseIdentifier: String?) {
|
||||||
|
super.init(reuseIdentifier: reuseIdentifier)
|
||||||
|
automaticallyUpdatesBackgroundConfiguration = false
|
||||||
|
|
||||||
|
contentView.addSubview(logoImageView)
|
||||||
|
contentView.addSubview(appNameLabel)
|
||||||
|
contentView.addSubview(versionLabel)
|
||||||
|
|
||||||
|
logoImageView.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.top.equalToSuperview().offset(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
appNameLabel.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.top.equalTo(logoImageView.snp.bottom).offset(7)
|
||||||
|
}
|
||||||
|
|
||||||
|
versionLabel.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.top.equalTo(appNameLabel.snp.bottom).offset(7)
|
||||||
|
make.bottom.equalToSuperview().offset(-30)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
66
XSeri/Class/Mine/View/XSMineCell.swift
Normal file
66
XSeri/Class/Mine/View/XSMineCell.swift
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
//
|
||||||
|
// XSMineCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSMineCell: XSTableViewCell {
|
||||||
|
|
||||||
|
var item: XSMineItem? {
|
||||||
|
didSet {
|
||||||
|
iconImageView.image = item?.iconImage
|
||||||
|
titleLabel.text = item?.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var iconImageView: UIImageView = {
|
||||||
|
let imageView = UIImageView()
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .bold)
|
||||||
|
label.textColor = .FFDAA_4
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSMineCell {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
contentView.addSubview(iconImageView)
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
contentView.addSubview(xs_indicatorImageView)
|
||||||
|
|
||||||
|
iconImageView.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.left.equalToSuperview().offset(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.left.equalToSuperview().offset(44)
|
||||||
|
}
|
||||||
|
|
||||||
|
xs_indicatorImageView.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.right.equalToSuperview().offset(-16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
78
XSeri/Class/Mine/View/XSMineHeaderView.swift
Normal file
78
XSeri/Class/Mine/View/XSMineHeaderView.swift
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
//
|
||||||
|
// XSMineHeaderView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSMineHeaderView: UITableViewHeaderFooterView {
|
||||||
|
|
||||||
|
var userInfo: XSUserInfo? {
|
||||||
|
didSet {
|
||||||
|
userInfoView.userInfo = userInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var historyDataArr: [XSShortModel] = [] {
|
||||||
|
didSet {
|
||||||
|
historyView.dataArr = historyDataArr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private lazy var stackView: UIStackView = {
|
||||||
|
let view = UIStackView()
|
||||||
|
view.axis = .vertical
|
||||||
|
view.spacing = 16
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var userInfoView: XSMineUserInfoView = {
|
||||||
|
let view = XSMineUserInfoView()
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var historyView: XSMinePlayHistoryView = {
|
||||||
|
let view = XSMinePlayHistoryView()
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(reuseIdentifier: String?) {
|
||||||
|
super.init(reuseIdentifier: reuseIdentifier)
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateLayout() {
|
||||||
|
stackView.xs_removeAllArrangedSubview()
|
||||||
|
|
||||||
|
stackView.addArrangedSubview(userInfoView)
|
||||||
|
|
||||||
|
if !historyDataArr.isEmpty {
|
||||||
|
stackView.addArrangedSubview(historyView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSMineHeaderView {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
contentView.addSubview(stackView)
|
||||||
|
|
||||||
|
stackView.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(6)
|
||||||
|
make.bottom.equalToSuperview().offset(-20)
|
||||||
|
make.left.right.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
86
XSeri/Class/Mine/View/XSMinePlayHistoryCell.swift
Normal file
86
XSeri/Class/Mine/View/XSMinePlayHistoryCell.swift
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
//
|
||||||
|
// XSMinePlayHistoryCell.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSMinePlayHistoryCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
var model: XSShortModel? {
|
||||||
|
didSet {
|
||||||
|
coverImageView.xs_setImage(model?.image_url)
|
||||||
|
titleLabel.text = model?.name
|
||||||
|
|
||||||
|
let epString = NSMutableAttributedString(string: "EP.\(model?.current_episode ?? "0")")
|
||||||
|
epString.yy_color = .FFDAA_4
|
||||||
|
|
||||||
|
let totalEpString = NSMutableAttributedString(string: "/\(model?.episode_total ?? 0)")
|
||||||
|
totalEpString.yy_color = .white.withAlphaComponent(0.4)
|
||||||
|
|
||||||
|
epString.append(totalEpString)
|
||||||
|
|
||||||
|
epLabel.attributedText = epString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var coverImageView: XSImageView = {
|
||||||
|
let imageView = XSImageView()
|
||||||
|
imageView.layer.cornerRadius = 4
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 12, weight: .medium)
|
||||||
|
label.textColor = .white
|
||||||
|
label.numberOfLines = 2
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var epLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 12, weight: .medium)
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSMinePlayHistoryCell {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
contentView.addSubview(coverImageView)
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
contentView.addSubview(epLabel)
|
||||||
|
|
||||||
|
coverImageView.snp.makeConstraints { make in
|
||||||
|
make.left.right.top.equalToSuperview()
|
||||||
|
make.bottom.equalToSuperview().offset(-58)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview()
|
||||||
|
make.top.equalTo(coverImageView.snp.bottom).offset(12)
|
||||||
|
make.right.lessThanOrEqualToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
epLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview()
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
129
XSeri/Class/Mine/View/XSMinePlayHistoryView.swift
Normal file
129
XSeri/Class/Mine/View/XSMinePlayHistoryView.swift
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
//
|
||||||
|
// XSMinePlayHistoryView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSMinePlayHistoryView: UIView {
|
||||||
|
|
||||||
|
|
||||||
|
var dataArr: [XSShortModel] = [] {
|
||||||
|
didSet {
|
||||||
|
self.collectionView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var moreButton: UIControl = {
|
||||||
|
let button = UIButton(primaryAction: UIAction(handler: { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.viewController?.tabBarController?.selectedIndex = 2
|
||||||
|
}))
|
||||||
|
return button
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var iconImageView = UIImageView(image: UIImage(named: "history_icon_01"))
|
||||||
|
|
||||||
|
private lazy var titleLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 14, weight: .bold)
|
||||||
|
label.textColor = .FFDAA_4
|
||||||
|
label.text = "Browsing History".localized
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var indicatorImageView = UIImageView(image: UIImage(named: "arrow_right_icon_01"))
|
||||||
|
|
||||||
|
private lazy var collectionViewLayout: UICollectionViewFlowLayout = {
|
||||||
|
let layout = UICollectionViewFlowLayout()
|
||||||
|
layout.scrollDirection = .horizontal
|
||||||
|
layout.itemSize = .init(width: 109, height: 203)
|
||||||
|
layout.minimumLineSpacing = 8
|
||||||
|
return layout
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var collectionView: XSCollectionView = {
|
||||||
|
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.dataSource = self
|
||||||
|
collectionView.showsHorizontalScrollIndicator = false
|
||||||
|
collectionView.contentInset = .init(top: 0, left: 16, bottom: 0, right: 16)
|
||||||
|
collectionView.register(XSMinePlayHistoryCell.self, forCellWithReuseIdentifier: "cell")
|
||||||
|
return collectionView
|
||||||
|
}()
|
||||||
|
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSMinePlayHistoryView {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
addSubview(moreButton)
|
||||||
|
moreButton.addSubview(iconImageView)
|
||||||
|
moreButton.addSubview(titleLabel)
|
||||||
|
moreButton.addSubview(indicatorImageView)
|
||||||
|
addSubview(collectionView)
|
||||||
|
|
||||||
|
moreButton.snp.makeConstraints { make in
|
||||||
|
make.left.right.equalToSuperview()
|
||||||
|
make.top.equalToSuperview()
|
||||||
|
make.height.equalTo(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
iconImageView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview().offset(16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.left.equalTo(iconImageView.snp.right).offset(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
indicatorImageView.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.right.equalToSuperview().offset(-16)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.left.right.equalToSuperview()
|
||||||
|
make.top.equalTo(moreButton.snp.bottom).offset(23)
|
||||||
|
make.height.equalTo(self.collectionViewLayout.itemSize.height)
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: UICollectionViewDelegate UICollectionViewDataSource
|
||||||
|
extension XSMinePlayHistoryView: UICollectionViewDelegate, UICollectionViewDataSource {
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! XSMinePlayHistoryCell
|
||||||
|
cell.model = self.dataArr[indexPath.row]
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
return dataArr.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
let model = self.dataArr[indexPath.row]
|
||||||
|
let vc = XSShortDetailViewController()
|
||||||
|
vc.shortId = model.short_play_id
|
||||||
|
self.viewController?.navigationController?.pushViewController(vc, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
XSeri/Class/Mine/View/XSMineUserInfoView.swift
Normal file
79
XSeri/Class/Mine/View/XSMineUserInfoView.swift
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// XSMineUserInfoView.swift
|
||||||
|
// XSeri
|
||||||
|
//
|
||||||
|
// Created by 长沙鸿瑶 on 2026/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SnapKit
|
||||||
|
|
||||||
|
class XSMineUserInfoView: UIView {
|
||||||
|
|
||||||
|
var userInfo: XSUserInfo? {
|
||||||
|
didSet {
|
||||||
|
avatarImageView.xs_setImage(userInfo?.avator, placeholder: UIImage(named: "avatar_placeholder_icon_01"))
|
||||||
|
nickNameLabel.text = userInfo?.getNickName()
|
||||||
|
idLabel.text = "ID: \(userInfo?.customer_id ?? "")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var avatarImageView: XSImageView = {
|
||||||
|
let imageView = XSImageView()
|
||||||
|
imageView.layer.cornerRadius = 28
|
||||||
|
return imageView
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var nickNameLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 16, weight: .bold)
|
||||||
|
label.textColor = .white
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var idLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = .font(ofSize: 12, weight: .medium)
|
||||||
|
label.textColor = .white.withAlphaComponent(0.6)
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
xs_setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XSMineUserInfoView {
|
||||||
|
|
||||||
|
private func xs_setupUI() {
|
||||||
|
addSubview(avatarImageView)
|
||||||
|
addSubview(nickNameLabel)
|
||||||
|
addSubview(idLabel)
|
||||||
|
|
||||||
|
avatarImageView.snp.makeConstraints { make in
|
||||||
|
make.left.equalToSuperview().offset(16)
|
||||||
|
make.top.equalToSuperview().offset(4)
|
||||||
|
make.height.width.equalTo(56)
|
||||||
|
make.bottom.equalToSuperview().offset(-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
nickNameLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(avatarImageView.snp.right).offset(10)
|
||||||
|
make.top.equalTo(avatarImageView).offset(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
idLabel.snp.makeConstraints { make in
|
||||||
|
make.left.equalTo(nickNameLabel)
|
||||||
|
make.bottom.equalTo(avatarImageView).offset(-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user