From 3ede7ea23644d861b0bd22b255b65973505428f4 Mon Sep 17 00:00:00 2001 From: xaoxuu Date: Thu, 17 Aug 2023 15:56:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9ECapsule=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PHDemo/PHDemo.xcodeproj/project.pbxproj | 4 + PHDemo/PHDemo/AlertVC.swift | 2 +- PHDemo/PHDemo/Base.lproj/Main.storyboard | 144 ++++++++++++-- PHDemo/PHDemo/CapsuleVC.swift | 187 ++++++++++++++++++ PHDemo/PHDemo/ListVC.swift | 20 +- PHDemo/PHDemo/SheetVC.swift | 2 +- PHDemo/PHDemo/TableHeaderView.swift | 25 +-- PHDemo/PHDemo/ToastVC.swift | 4 +- Sources/ProHUD/Alert/Alert.swift | 8 +- Sources/ProHUD/Alert/AlertDefaultLayout.swift | 16 +- Sources/ProHUD/Alert/AlertManager.swift | 10 +- Sources/ProHUD/Alert/AlertWindow.swift | 25 +-- Sources/ProHUD/Capsule/Capsule.swift | 129 ++++++++++++ .../ProHUD/Capsule/CapsuleConfiguration.swift | 57 ++++++ .../ProHUD/Capsule/CapsuleDefaultLayout.swift | 97 +++++++++ Sources/ProHUD/Capsule/CapsuleManager.swift | 186 +++++++++++++++++ Sources/ProHUD/Capsule/CapsuleWindow.swift | 43 ++++ .../ProHUD/Core/Models/Configuration.swift | 10 +- Sources/ProHUD/Core/Models/ViewModel.swift | 88 +++++---- Sources/ProHUD/Core/Utils/AppContext.swift | 8 +- Sources/ProHUD/Core/Utils/Utils.swift | 18 ++ Sources/ProHUD/Core/Utils/ViewExts.swift | 2 +- Sources/ProHUD/Sheet/Sheet.swift | 4 +- Sources/ProHUD/Sheet/SheetManager.swift | 43 +++- Sources/ProHUD/Sheet/SheetWindow.swift | 71 ++----- Sources/ProHUD/Toast/Toast.swift | 16 +- Sources/ProHUD/Toast/ToastDefaultLayout.swift | 12 +- Sources/ProHUD/Toast/ToastManager.swift | 128 +++++++++++- Sources/ProHUD/Toast/ToastWindow.swift | 157 ++------------- 29 files changed, 1174 insertions(+), 342 deletions(-) create mode 100644 PHDemo/PHDemo/CapsuleVC.swift create mode 100644 Sources/ProHUD/Capsule/Capsule.swift create mode 100644 Sources/ProHUD/Capsule/CapsuleConfiguration.swift create mode 100644 Sources/ProHUD/Capsule/CapsuleDefaultLayout.swift create mode 100644 Sources/ProHUD/Capsule/CapsuleManager.swift create mode 100644 Sources/ProHUD/Capsule/CapsuleWindow.swift diff --git a/PHDemo/PHDemo.xcodeproj/project.pbxproj b/PHDemo/PHDemo.xcodeproj/project.pbxproj index e3cb902..915b787 100644 --- a/PHDemo/PHDemo.xcodeproj/project.pbxproj +++ b/PHDemo/PHDemo.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + CD2439342A82164E00A3BBF5 /* CapsuleVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2439332A82164E00A3BBF5 /* CapsuleVC.swift */; }; CD6537BF28C3311B00A5981B /* ListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6537BE28C3311B00A5981B /* ListModel.swift */; }; CD6537C128C35E1C00A5981B /* ListVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6537C028C35E1C00A5981B /* ListVC.swift */; }; CD6537C328C35E6200A5981B /* ToastVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6537C228C35E6200A5981B /* ToastVC.swift */; }; @@ -26,6 +27,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + CD2439332A82164E00A3BBF5 /* CapsuleVC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CapsuleVC.swift; sourceTree = ""; }; CD6537BE28C3311B00A5981B /* ListModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListModel.swift; sourceTree = ""; }; CD6537C028C35E1C00A5981B /* ListVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListVC.swift; sourceTree = ""; }; CD6537C228C35E6200A5981B /* ToastVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastVC.swift; sourceTree = ""; }; @@ -86,6 +88,7 @@ CD6537C228C35E6200A5981B /* ToastVC.swift */, CDB7A1CF28C32A7400E034D8 /* AlertVC.swift */, CD6537C428C35F2C00A5981B /* SheetVC.swift */, + CD2439332A82164E00A3BBF5 /* CapsuleVC.swift */, CD8EEF4028BC5C7200E660EA /* Main.storyboard */, CD8EEF4328BC5C7300E660EA /* Assets.xcassets */, CD8EEF4528BC5C7300E660EA /* LaunchScreen.storyboard */, @@ -184,6 +187,7 @@ files = ( CDA83DB928C601E60025F0DF /* TableHeaderView.swift in Sources */, CD6537C528C35F2C00A5981B /* SheetVC.swift in Sources */, + CD2439342A82164E00A3BBF5 /* CapsuleVC.swift in Sources */, CD6537C328C35E6200A5981B /* ToastVC.swift in Sources */, CDB7A1D028C32A7400E034D8 /* AlertVC.swift in Sources */, CD6537C128C35E1C00A5981B /* ListVC.swift in Sources */, diff --git a/PHDemo/PHDemo/AlertVC.swift b/PHDemo/PHDemo/AlertVC.swift index 752c807..fc8b37d 100644 --- a/PHDemo/PHDemo/AlertVC.swift +++ b/PHDemo/PHDemo/AlertVC.swift @@ -15,7 +15,7 @@ class AlertVC: ListVC { // Uncomment the following line to preserve selection between presentations // self.clearsSelectionOnViewWillAppear = false - header.titleLabel.text = "ProHUD.Alert" + title = "Alert" header.detailLabel.text = "弹窗控件,用于强阻塞性交互,用户必须做出选择或者等待结果才能进入下一步,当多个实例出现时,会以堆叠的形式显示,新的实例会在覆盖旧的实例上层。" Alert.Configuration.shared { config in diff --git a/PHDemo/PHDemo/Base.lproj/Main.storyboard b/PHDemo/PHDemo/Base.lproj/Main.storyboard index a90e296..a19e0c5 100644 --- a/PHDemo/PHDemo/Base.lproj/Main.storyboard +++ b/PHDemo/PHDemo/Base.lproj/Main.storyboard @@ -1,14 +1,14 @@ - + - + - + @@ -32,11 +32,11 @@ - + - + @@ -50,16 +50,17 @@ - - - + + + + - + @@ -83,13 +84,13 @@ - + - + - + @@ -113,19 +114,126 @@ - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + - + diff --git a/PHDemo/PHDemo/CapsuleVC.swift b/PHDemo/PHDemo/CapsuleVC.swift new file mode 100644 index 0000000..585b9ce --- /dev/null +++ b/PHDemo/PHDemo/CapsuleVC.swift @@ -0,0 +1,187 @@ +// +// CapsuleVC.swift +// PHDemo +// +// Created by xaoxuu on 2022/9/3. +// + +import UIKit +import ProHUD + +class CapsuleVC: ListVC { + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Capsule" + header.detailLabel.text = "状态胶囊控件,用于状态显示,一个主程序窗口每个位置(上中下)各自最多只有一个状态胶囊实例。" + + Capsule.Configuration.shared { config in +// config.cardCornerRadius = .infinity // 设置一个较大的数字就会变成胶囊形状 + } + list.add(title: "默认布局:纯文字") { section in + section.add(title: "一条简短的消息") { + Capsule(.message("一条简短的消息")).push() + } + section.add(title: "一条稍微长一点的消息") { + Capsule(.message("一条稍微长一点的消息")).push() + } + section.add(title: "(默认)状态胶囊控件,用于状态显示,一个主程序窗口只有一个状态胶囊实例。") { + Capsule(.message("状态胶囊控件,用于状态显示,一个主程序窗口只有一个状态胶囊实例。")).push() + } + section.add(title: "(限制1行)状态胶囊控件,用于状态显示,一个主程序窗口只有一个状态胶囊实例。") { + Capsule(.message("状态胶囊控件,用于状态显示,一个主程序窗口只有一个状态胶囊实例。")) { capsule in + capsule.config.customTextLabel { label in + label.numberOfLines = 1 + } + } + } + } + + list.add(title: "默认布局:图文") { section in + section.add(title: "一条简短的消息") { + Capsule(.info("一条简短的消息")).push() + } + section.add(title: "一条稍微长一点的消息") { + Capsule(.info("一条稍微长一点的消息")).push() + } + section.add(title: "(默认)状态胶囊控件,用于状态显示,一个主程序窗口只有一个状态胶囊实例。") { + Capsule(.info("状态胶囊控件,用于状态显示,一个主程序窗口只有一个状态胶囊实例。")).push() + } + section.add(title: "(限制1行)状态胶囊控件,用于状态显示,一个主程序窗口只有一个状态胶囊实例。") { + Capsule(.info("状态胶囊控件,用于状态显示,一个主程序窗口只有一个状态胶囊实例。")) { capsule in + capsule.config.customTextLabel { label in + label.numberOfLines = 1 + } + } + } + } + + list.add(title: "不同位置、不同动画") { section in + section.add(title: "顶部,缩放") { + Capsule(.info("一条简短的消息")) { capsule in + capsule.config.animateBuildIn { window, completion in + let duration = 1.0 + let d0 = duration * 0.2 + let d1 = duration + window.transform = .init(scaleX: 0.001, y: 0.001) + window.alpha = 0 + UIView.animate(withDuration: d0, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0.5) { + window.transform = .init(scaleX: 0.01, y: 0.5) + } + UIView.animate(withDuration: d1, delay: d0 * 0.2, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5) { + window.transform = .identity + } completion: { done in + completion() + } + UIView.animate(withDuration: duration * 0.4, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 1) { + window.alpha = 1 + } + } + capsule.config.animateBuildOut { window, completion in + let duration = 0.8 + UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 1) { + window.transform = .init(scaleX: 0.0001, y: 0.5) + } completion: { done in + completion() + } + UIView.animate(withDuration: duration * 0.6, delay: duration * 0.4, usingSpringWithDamping: 1, initialSpringVelocity: 1) { + window.alpha = 0 + } + } + } + } + section.add(title: "中间,黑底白字,透明渐变") { + Capsule(.middle.info("一条简短的消息")) { capsule in + capsule.config.tintColor = .white + capsule.config.cardCornerRadius = 4 + capsule.config.contentViewMask { mask in + mask.layer.backgroundColor = UIColor.black.withAlphaComponent(0.75).cgColor + } + capsule.config.customTextLabel { label in + label.textColor = .white + } + capsule.config.animateBuildIn { window, completion in + window.alpha = 0 + UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0.5) { + window.alpha = 1 + } completion: { done in + completion() + } + } + capsule.config.animateBuildOut { window, completion in + UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0.5) { + window.alpha = 0 + } completion: { done in + completion() + } + } + } + } + section.add(title: "底部,渐变背景,回弹滑入") { + Capsule(.bottom.enter("点击进入")) { capsule in + capsule.config.tintColor = .white + capsule.config.customTextLabel { label in + label.textColor = .white + } + capsule.config.contentViewMask { mask in + mask.effect = .none + mask.backgroundColor = .clear + let gradientLayer = CAGradientLayer() + gradientLayer.frame = self.view.bounds + gradientLayer.colors = [UIColor("#0091FF").cgColor, UIColor("#00FDFF").cgColor] + gradientLayer.startPoint = .init(x: 0.2, y: 0.6) + gradientLayer.endPoint = .init(x: 0.6, y: 0.2) + gradientLayer.frame = .init(x: 0, y: 0, width: 300, height: 100) + mask.layer.sublayers?.forEach({ $0.removeFromSuperlayer() }) + mask.layer.insertSublayer(gradientLayer, at: 0) + } + capsule.config.cardCornerRadius = .infinity + capsule.config.animateBuildIn { window, completion in + window.transform = .init(translationX: 0, y: 240) + window.alpha = 0 + UIView.animate(withDuration: 0.8, delay: 0, usingSpringWithDamping: 0.75, initialSpringVelocity: 0.5) { + window.transform = .identity + window.alpha = 1 + } completion: { done in + completion() + } + } + capsule.config.animateBuildOut { window, completion in + UIView.animate(withDuration: 0.8, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0.5) { + window.transform = .init(translationX: 0, y: 240) + window.alpha = 0 + } completion: { done in + completion() + } + } + }.onTapped { capsule in + Alert(.message("收到点击事件").duration(1)).push() + capsule.pop() + } + } + } + } + +} + +extension Capsule.CapsuleViewModel { + + static func info(_ text: String?) -> Self { + .init() + .info(text) + } + + func info(_ text: String?) -> Self { + self.message(text) + .icon(.init(systemName: "info.circle.fill")) +// .duration(2) + } + + func enter(_ text: String?) -> Self { + self.message(text) + .icon(.init(systemName: "arrow.right.circle.fill")) + .duration(.infinity) + } + +} diff --git a/PHDemo/PHDemo/ListVC.swift b/PHDemo/PHDemo/ListVC.swift index d83938a..1d2291a 100644 --- a/PHDemo/PHDemo/ListVC.swift +++ b/PHDemo/PHDemo/ListVC.swift @@ -12,7 +12,7 @@ class ListVC: UITableViewController { var list = ListModel() - lazy var header: TableHeaderView = TableHeaderView(text: "ProHUD") + lazy var header: TableHeaderView = TableHeaderView() override func viewDidLoad() { super.viewDidLoad() @@ -21,8 +21,24 @@ class ListVC: UITableViewController { tableView.sectionHeaderHeight = 32 tableView.sectionFooterHeight = 8 + navigationController?.navigationBar.prefersLargeTitles = true + navigationItem.leftBarButtonItem = .init(title: "ProHUD", style: .done, target: self, action: #selector(self.onTappedLeftBarButtonItem(_:))) + navigationItem.rightBarButtonItem = .init(image: .init(systemName: "questionmark.circle.fill"), style: .done, target: self, action: #selector(self.onTappedRightBarButtonItem(_:))) + } - + + + @objc func onTappedLeftBarButtonItem(_ sender: UIBarButtonItem) { + guard let url = URL(string: "https://github.com/xaoxuu/ProHUD") else { return } + UIApplication.shared.open(url) + } + + @objc func onTappedRightBarButtonItem(_ sender: UIBarButtonItem) { + guard let url = URL(string: "https://xaoxuu.com/wiki/prohud/") else { return } + UIApplication.shared.open(url) + } + + override func numberOfSections(in tableView: UITableView) -> Int { // #warning Incomplete implementation, return the number of sections diff --git a/PHDemo/PHDemo/SheetVC.swift b/PHDemo/PHDemo/SheetVC.swift index b6fd71a..fc9c0f7 100644 --- a/PHDemo/PHDemo/SheetVC.swift +++ b/PHDemo/PHDemo/SheetVC.swift @@ -13,7 +13,7 @@ class SheetVC: ListVC { override func viewDidLoad() { super.viewDidLoad() - header.titleLabel.text = "ProHUD.Sheet" + title = "Sheet" header.detailLabel.text = "操作表控件,用于弱阻塞性交互。显示区域为从屏幕底部向上弹出的新图层,可以放置丰富的内容,自由度较高。" list.add(title: "默认布局") { section in diff --git a/PHDemo/PHDemo/TableHeaderView.swift b/PHDemo/PHDemo/TableHeaderView.swift index 7713945..8d347dc 100644 --- a/PHDemo/PHDemo/TableHeaderView.swift +++ b/PHDemo/PHDemo/TableHeaderView.swift @@ -9,16 +9,8 @@ import UIKit class TableHeaderView: UIView { - lazy var titleLabel: UILabel = { - let lb = UILabel(frame: .init(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 80)) - lb.font = .systemFont(ofSize: 32, weight: .black) - lb.textAlignment = .center - lb.text = "ProHUD" - return lb - }() - lazy var detailLabel: UILabel = { - let lb = UILabel(frame: .init(x: 0, y: 80, width: UIScreen.main.bounds.width, height: 120)) + let lb = UILabel(frame: .init(x: 0, y: 80, width: UIScreen.main.bounds.width, height: 80)) lb.font = .systemFont(ofSize: 15, weight: .regular) lb.textAlignment = .justified lb.numberOfLines = 0 @@ -26,23 +18,18 @@ class TableHeaderView: UIView { return lb }() - convenience init(text: String) { - self.init(frame: .init(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 150)) - titleLabel.text = text + convenience init() { + self.init(frame: .init(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 80)) } override init(frame: CGRect) { super.init(frame: frame) addSubview(detailLabel) - addSubview(titleLabel) - titleLabel.snp.makeConstraints { make in - make.left.right.equalToSuperview().inset(24) - make.top.equalToSuperview().offset(28) - } + detailLabel.snp.makeConstraints { make in - make.left.right.equalTo(titleLabel) - make.top.equalTo(titleLabel.snp.bottom).offset(12) + make.left.right.equalToSuperview().inset(24) + make.top.equalToSuperview().offset(8) } } diff --git a/PHDemo/PHDemo/ToastVC.swift b/PHDemo/PHDemo/ToastVC.swift index 7db3a56..5616ac1 100644 --- a/PHDemo/PHDemo/ToastVC.swift +++ b/PHDemo/PHDemo/ToastVC.swift @@ -47,9 +47,11 @@ class ToastVC: ListVC { override func viewDidLoad() { super.viewDidLoad() + title = "Toast" + let title = "通知条控件" let message = "通知条控件,用于非阻塞性事件通知。显示效果如同原生通知,默认会自动消失,可以支持手势移除,有多条通知可以平铺并列显示。" - header.titleLabel.text = "ProHUD.Toast" + header.detailLabel.text = message Toast.Configuration.shared { config in diff --git a/Sources/ProHUD/Alert/Alert.swift b/Sources/ProHUD/Alert/Alert.swift index c3f8119..0468878 100644 --- a/Sources/ProHUD/Alert/Alert.swift +++ b/Sources/ProHUD/Alert/Alert.swift @@ -82,11 +82,9 @@ open class Alert: ProHUD.Controller { } } - @discardableResult public init(_ vm: ViewModel?, handler: ((_ alert: Alert) -> Void)? = nil) { + @discardableResult public init(_ vm: ViewModel, handler: ((_ alert: Alert) -> Void)? = nil) { super.init() - if let vm = vm { - self.vm = vm - } + self.vm = vm handler?(self) DispatchQueue.main.async { if handler != nil { @@ -97,7 +95,7 @@ open class Alert: ProHUD.Controller { } @discardableResult public convenience init(handler: ((_ alert: Alert) -> Void)?) { - self.init(nil, handler: handler) + self.init(.init(), handler: handler) } public override func viewDidLoad() { diff --git a/Sources/ProHUD/Alert/AlertDefaultLayout.swift b/Sources/ProHUD/Alert/AlertDefaultLayout.swift index da698bd..3f3b174 100644 --- a/Sources/ProHUD/Alert/AlertDefaultLayout.swift +++ b/Sources/ProHUD/Alert/AlertDefaultLayout.swift @@ -68,7 +68,7 @@ extension Alert: DefaultLayout { if contentView.superview != view { view.insertSubview(contentView, at: 0) } - let alerts = window?.alerts ?? [] + let alerts = attachedWindow?.alerts ?? [] if config.enableShadow && alerts.count > 0 { contentView.clipsToBounds = false contentView.layer.shadowRadius = 4 @@ -195,7 +195,11 @@ extension Alert { if bodyCount > 0 { config.customTitleLabel?(titleLabel) } else { - config.customTextLabel?(titleLabel) + if let customTextLabel = config.customTextLabel { + customTextLabel(titleLabel) + } else { + titleLabel.font = .boldSystemFont(ofSize: 18) + } } } else { if textStack.arrangedSubviews.contains(titleLabel) { @@ -211,7 +215,11 @@ extension Alert { if titleCount > 0 { config.customBodyLabel?(bodyLabel) } else { - config.customTextLabel?(bodyLabel) + if let customTextLabel = config.customTextLabel { + customTextLabel(bodyLabel) + } else { + bodyLabel.font = .boldSystemFont(ofSize: 18) + } } } else { if textStack.arrangedSubviews.contains(bodyLabel) { @@ -246,7 +254,7 @@ extension Alert { public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - let alerts = window?.alerts ?? [] + let alerts = attachedWindow?.alerts ?? [] if config.enableShadow && alerts.count > 1 { contentView.layer.shadowPath = UIBezierPath.init(rect: contentView.bounds).cgPath } diff --git a/Sources/ProHUD/Alert/AlertManager.swift b/Sources/ProHUD/Alert/AlertManager.swift index 537578b..4835f23 100644 --- a/Sources/ProHUD/Alert/AlertManager.swift +++ b/Sources/ProHUD/Alert/AlertManager.swift @@ -39,7 +39,6 @@ extension Alert: HUD { @objc open func pop() { navEvents[.onViewWillDisappear]?(self) - let window = window ?? createAttachedWindowIfNotExists() Alert.removeAlert(alert: self) let duration = config.animateDurationForBuildOut ?? config.animateDurationForBuildOutByDefault UIView.animateEaseOut(duration: duration) { @@ -51,9 +50,10 @@ extension Alert: HUD { self.navEvents[.onViewDidDisappear]?(self) } // hide window + guard let window = view.window as? AlertWindow, let windowScene = windowScene else { return } let count = window.alerts.count if count == 0 { - self.window = nil + AppContext.alertWindow[windowScene] = nil UIView.animateEaseOut(duration: duration) { window.backgroundView.alpha = 0 } completion: { done in @@ -106,6 +106,8 @@ public extension Alert { } +// MARK: - layout + fileprivate extension Alert { static func updateAlertsLayout(alerts: [Alert]) { for (i, a) in alerts.reversed().enumerated() { @@ -124,7 +126,7 @@ fileprivate extension Alert { } static func removeAlert(alert: Alert) { - guard var alerts = alert.window?.alerts else { + guard var alerts = alert.attachedWindow?.alerts else { return } if alerts.count > 1 { @@ -141,7 +143,7 @@ fileprivate extension Alert { } else { print("‼️代码漏洞:已经没有alert了") } - alert.window?.alerts = alerts + alert.attachedWindow?.alerts = alerts } } diff --git a/Sources/ProHUD/Alert/AlertWindow.swift b/Sources/ProHUD/Alert/AlertWindow.swift index 29e43e6..9a03b7a 100644 --- a/Sources/ProHUD/Alert/AlertWindow.swift +++ b/Sources/ProHUD/Alert/AlertWindow.swift @@ -7,23 +7,6 @@ import UIKit -extension Alert { - var window: AlertWindow? { - get { - guard let windowScene = windowScene else { - return nil - } - return AppContext.alertWindow[windowScene] - } - set { - guard let windowScene = windowScene else { - return - } - AppContext.alertWindow[windowScene] = newValue - } - } -} - class AlertWindow: Window { var alerts: [Alert] = [] @@ -45,8 +28,14 @@ class AlertWindow: Window { AppContext.alertWindow[windowScene] = w } // 比原生alert层级低一点 - w.windowLevel = .init(rawValue: UIWindow.Level.alert.rawValue - 1) + w.windowLevel = .phAlert return w } } + +extension Alert { + var attachedWindow: AlertWindow? { + view.window as? AlertWindow + } +} diff --git a/Sources/ProHUD/Capsule/Capsule.swift b/Sources/ProHUD/Capsule/Capsule.swift new file mode 100644 index 0000000..7a7cdbc --- /dev/null +++ b/Sources/ProHUD/Capsule/Capsule.swift @@ -0,0 +1,129 @@ +// +// Capsule.swift +// +// +// Created by xaoxuu on 2022/9/8. +// + +import UIKit + +open class Capsule: Controller { + + public lazy var config: Configuration = { + var cfg = Configuration() + Configuration.customShared?(cfg) + return cfg + }() + + /// 内容容器(imageView、textLabel) + public lazy var contentStack: StackView = { + let stack = StackView() + stack.spacing = 8 + stack.distribution = .equalCentering + stack.alignment = .fill + stack.axis = .horizontal + config.customContentStack?(stack) + return stack + }() + + /// 图标 + public lazy var imageView: UIImageView = { + let imgv = UIImageView() + imgv.contentMode = .scaleAspectFit + return imgv + }() + + /// 文本 + public lazy var textLabel: UILabel = { + let lb = UILabel() + lb.textColor = config.primaryLabelColor + lb.font = .boldSystemFont(ofSize: 15) + lb.textAlignment = .justified + lb.numberOfLines = 2 + config.customTextLabel?(lb) + return lb + }() + + open class CapsuleViewModel: ViewModel { + + public enum Position { + case top + case middle + case bottom + } + public var position: Position = .top + + public static var top: Self { + let obj = Self.init() + obj.position = .top + return obj + } + public static var middle: Self { + let obj = Self.init() + obj.position = .middle + return obj + } + public static var bottom: Self { + let obj = Self.init() + obj.position = .bottom + return obj + } + + } + + /// 视图模型 + public var vm = CapsuleViewModel() + + private var tapActionCallback: ((_ capsule: Capsule) -> Void)? + + @discardableResult public init(_ vm: CapsuleViewModel, handler: ((_ capsule: Capsule) -> Void)? = nil) { + super.init() + self.vm = vm + handler?(self) + DispatchQueue.main.async { + if handler != nil { + self.push() + } + } + } + + @discardableResult public convenience init(handler: ((_ capsule: Capsule) -> Void)?) { + self.init(.init(), handler: handler) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + view.tintColor = config.tintColor + view.layer.shadowRadius = 8 + view.layer.shadowOffset = .init(width: 0, height: 5) + view.layer.shadowOpacity = 0.1 + + // 点击 + let tap = UITapGestureRecognizer(target: self, action: #selector(_onTappedGesture(_:))) + view.addGestureRecognizer(tap) + + reloadData(animated: false) + + navEvents[.onViewDidLoad]?(self) + + } + + public func onTapped(action: @escaping (_ capsule: Capsule) -> Void) { + self.tapActionCallback = action + } + +} + +fileprivate extension Capsule { + + /// 点击事件 + /// - Parameter sender: 手势 + @objc func _onTappedGesture(_ sender: UITapGestureRecognizer) { + tapActionCallback?(self) + } + +} diff --git a/Sources/ProHUD/Capsule/CapsuleConfiguration.swift b/Sources/ProHUD/Capsule/CapsuleConfiguration.swift new file mode 100644 index 0000000..6c8c5b6 --- /dev/null +++ b/Sources/ProHUD/Capsule/CapsuleConfiguration.swift @@ -0,0 +1,57 @@ +// +// CapsuleConfiguration.swift +// +// +// Created by xaoxuu on 2022/9/8. +// + +import UIKit + +public extension Capsule { + + typealias CustomAnimateHandler = ((_ window: UIWindow, _ completion: @escaping () -> Void) -> Void) + + class Configuration: ProHUD.Configuration { + + static var customShared: ((_ config: Configuration) -> Void)? + + /// 共享配置(只能设置一次,影响所有实例) + /// - Parameter callback: 配置代码 + public static func shared(_ callback: @escaping (_ config: Configuration) -> Void) { + customShared = callback + } + + override var cardCornerRadiusByDefault: CGFloat { + cardCornerRadius ?? 16 + } + + override var cardEdgeInsetsByDefault: UIEdgeInsets { + cardEdgeInsets ?? .init(top: 12, left: 16, bottom: 12, right: 16) + } + + override var cardMaxWidthByDefault: CGFloat { cardMaxWidth ?? 320 } + + override var cardMaxHeightByDefault: CGFloat { cardMaxHeight ?? 120 } + + override var animateDurationForBuildInByDefault: CGFloat { + animateDurationForBuildIn ?? 0.8 + } + + override var animateDurationForBuildOutByDefault: CGFloat { + animateDurationForBuildOut ?? 0.8 + } + + var animateBuildIn: CustomAnimateHandler? + public func animateBuildIn(_ handler: CustomAnimateHandler?) { + animateBuildIn = handler + } + + var animateBuildOut: CustomAnimateHandler? + public func animateBuildOut(_ handler: CustomAnimateHandler?) { + animateBuildOut = handler + } + + + } + +} diff --git a/Sources/ProHUD/Capsule/CapsuleDefaultLayout.swift b/Sources/ProHUD/Capsule/CapsuleDefaultLayout.swift new file mode 100644 index 0000000..dfdb763 --- /dev/null +++ b/Sources/ProHUD/Capsule/CapsuleDefaultLayout.swift @@ -0,0 +1,97 @@ +// +// CapsuleDefaultLayout.swift +// +// +// Created by xaoxuu on 2022/9/9. +// + +import UIKit + +extension Capsule: DefaultLayout { + + var cfg: ProHUD.Configuration { + return config + } + + func reloadData(animated: Bool) { + if self.cfg.customReloadData?(self) == true { + return + } + + // content + loadContentViewIfNeeded() + loadContentMaskViewIfNeeded() + + // image + imageView.removeFromSuperview() + if let icon = vm.icon { + contentStack.insertArrangedSubview(imageView, at: 0) + imageView.image = icon + } else { + contentStack.removeArrangedSubview(imageView) + } + + // text + textLabel.removeFromSuperview() + let text = (vm.title ?? "") + (vm.message ?? "") + if text.count > 0 { + contentStack.addArrangedSubview(textLabel) + textLabel.snp.makeConstraints { make in + make.width.lessThanOrEqualTo(AppContext.appBounds.width * 0.5) + } + textLabel.text = text + textLabel.sizeToFit() + } else { + contentStack.removeArrangedSubview(textLabel) + } + + view.layoutIfNeeded() + + // 更新时间 + updateTimeoutDuration() + + if isViewDisplayed { + UIView.animateEaseOut(duration: config.animateDurationForReloadByDefault) { + self.view.layoutIfNeeded() + } + } + + } + + private func loadContentViewIfNeeded() { + if contentView.superview != view { + view.insertSubview(contentView, at: 0) + } + + // layout + contentView.snp.remakeConstraints { make in + make.edges.equalToSuperview() + } + guard customView == nil else { + if contentStack.superview != nil { + contentStack.removeFromSuperview() + } + return + } + // stack + if contentStack.superview == nil { + view.addSubview(contentStack) + contentStack.snp.remakeConstraints { make in + make.center.equalToSuperview() + } + } + + } + + private func updateTimeoutDuration() { + // 为空时使用默认规则 + if vm.duration == nil { + vm.duration = 3 + } + // 设置持续时间 + vm.timeoutHandler = DispatchWorkItem(block: { [weak self] in + self?.pop() + }) + } + +} diff --git a/Sources/ProHUD/Capsule/CapsuleManager.swift b/Sources/ProHUD/Capsule/CapsuleManager.swift new file mode 100644 index 0000000..69bb04d --- /dev/null +++ b/Sources/ProHUD/Capsule/CapsuleManager.swift @@ -0,0 +1,186 @@ +// +// CapsuleManager.swift +// +// +// Created by xaoxuu on 2022/9/8. +// + +import UIKit + +extension Capsule: HUD { + + @objc open func push() { + guard Configuration.isEnabled else { return } + let isNew: Bool + let window: CapsuleWindow + let position = vm.position + + if let w = AppContext.current?.capsuleWindows[position] { + isNew = false + window = w + } else { + window = CapsuleWindow(capsule: self) + isNew = true + } + // frame + let cardEdgeInsetsByDefault = config.cardEdgeInsetsByDefault + view.layoutIfNeeded() + var size = contentStack.frame.size + size = CGSize(width: min(config.cardMaxWidthByDefault, size.width + cardEdgeInsetsByDefault.left + cardEdgeInsetsByDefault.right), height: min(config.cardMaxHeightByDefault, size.height + cardEdgeInsetsByDefault.top + cardEdgeInsetsByDefault.bottom)) + + // 应用到frame + let newFrame: CGRect + switch vm.position { + case .top: + let topLayoutMargins = AppContext.appWindow?.layoutMargins.top ?? 8 + let y = max(topLayoutMargins - 8, 8) + newFrame = .init(x: (AppContext.appBounds.width - size.width) / 2, y: y, width: size.width, height: size.height) + case .middle: + newFrame = .init(x: (AppContext.appBounds.width - size.width) / 2, y: (AppContext.appBounds.height - size.height) / 2 - 20, width: size.width, height: size.height) + case .bottom: + let bottomLayoutMargins = AppContext.appWindow?.layoutMargins.bottom ?? 8 + let y = AppContext.appBounds.height - bottomLayoutMargins - size.height - 60 + newFrame = .init(x: (AppContext.appBounds.width - size.width) / 2, y: y, width: size.width, height: size.height) + } + + window.transform = .identity + if isNew { + window.frame = newFrame + } + + config.cardCornerRadius = min(size.height / 2, config.cardCornerRadiusByDefault) + contentView.layer.cornerRadiusWithContinuous = config.cardCornerRadiusByDefault + view.layer.cornerRadiusWithContinuous = config.cardCornerRadiusByDefault + + window.rootViewController = self // 此时toast.view.frame.size会自动更新为window.frame.size + if let s = AppContext.windowScene { + if AppContext.capsuleWindows[s] == nil { + AppContext.capsuleWindows[s] = [:] + } + AppContext.capsuleWindows[s]?[position] = window + } + navEvents[.onViewWillAppear]?(self) + if isNew { + window.isHidden = false + if let animateBuildIn = config.animateBuildIn { + animateBuildIn(window) { + self.navEvents[.onViewDidAppear]?(self) + } + } else { + let duration = config.animateDurationForBuildInByDefault + window.transform = .init(translationX: 0, y: -window.frame.maxY - 20) + UIView.animateEaseOut(duration: duration) { + window.transform = .identity + } completion: { done in + self.navEvents[.onViewDidAppear]?(self) + } + } + } else { + view.layoutIfNeeded() + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) { + window.frame = newFrame + window.layoutIfNeeded() + } completion: { done in + self.navEvents[.onViewDidAppear]?(self) + } + } + + } + + @objc open func pop() { + guard let window = attachedWindow, let windowScene = windowScene else { return } + AppContext.capsuleWindows[windowScene]?[vm.position] = nil + navEvents[.onViewWillDisappear]?(self) + if let animateBuildOut = config.animateBuildOut { + animateBuildOut(window) { + window.isHidden = true + window.transform = .identity + self.navEvents[.onViewDidAppear]?(self) + } + } else { + let duration = config.animateDurationForBuildOutByDefault + UIView.animateEaseOut(duration: duration) { + window.transform = .init(translationX: 0, y: -window.frame.maxY - 20) + } completion: { done in + window.isHidden = true + window.transform = .identity + self.navEvents[.onViewDidDisappear]?(self) + } + } + } + + +} + +public extension Capsule { + + /// 如果不存在就创建并弹出一个HUD实例,如果存在就更新实例 + /// - Parameters: + /// - identifier: 实例唯一标识符(如果为空,则以代码位置为唯一标识符) + /// - handler: 实例创建代码 + static func lazyPush(identifier: String? = nil, file: String = #file, line: Int = #line, handler: @escaping (_ capsule: Capsule) -> Void, onExists: ((_ capsule: Capsule) -> Void)? = nil) { + let id = identifier ?? (file + "#\(line)") + if let vc = find(identifier: id).last { + vc.update(handler: onExists ?? handler) + } else { + Capsule { capsule in + capsule.identifier = id + handler(capsule) + } + } + } + + /// 更新HUD实例 + /// - Parameter handler: 实例更新代码 + func update(handler: @escaping (_ capsule: Capsule) -> Void) { + handler(self) + reloadData() + UIView.animateEaseOut(duration: config.animateDurationForReloadByDefault) { + self.view.layoutIfNeeded() + } + } + + /// 查找HUD实例 + /// - Parameter identifier: 唯一标识符 + /// - Returns: HUD实例 + @discardableResult static func find(identifier: String, update handler: ((_ capsule: Capsule) -> Void)? = nil) -> [Capsule] { + let arr = AppContext.capsuleWindows.values.flatMap({ $0.values }).compactMap({ $0.capsule }).filter({ $0.identifier == identifier }) + if let handler = handler { + arr.forEach({ $0.update(handler: handler) }) + } + return arr + } + +} + + +//extension Capsule { +// +// func translateIn(completion: (() -> Void)?) { +// UIView.animateEaseOut(duration: config.animateDurationForBuildInByDefault) { +// if self.config.stackDepthEffect { +// if isPhonePortrait { +// AppContext.appWindow?.transform = .init(translationX: 0, y: 8).scaledBy(x: 0.9, y: 0.9) +// } else { +// AppContext.appWindow?.transform = .init(scaleX: 0.92, y: 0.92) +// } +// AppContext.appWindow?.layer.cornerRadiusWithContinuous = 16 +// AppContext.appWindow?.layer.masksToBounds = true +// } +// } completion: { done in +// completion?() +// } +// } +// +// func translateOut(completion: (() -> Void)?) { +// UIView.animateEaseOut(duration: config.animateDurationForBuildOutByDefault) { +// if self.config.stackDepthEffect { +// AppContext.appWindow?.transform = .identity +// AppContext.appWindow?.layer.cornerRadius = 0 +// } +// } completion: { done in +// completion?() +// } +// } +// +//} diff --git a/Sources/ProHUD/Capsule/CapsuleWindow.swift b/Sources/ProHUD/Capsule/CapsuleWindow.swift new file mode 100644 index 0000000..87f1d06 --- /dev/null +++ b/Sources/ProHUD/Capsule/CapsuleWindow.swift @@ -0,0 +1,43 @@ +// +// CapsuleWindow.swift +// +// +// Created by xaoxuu on 2022/9/8. +// + +import UIKit + +class CapsuleWindow: Window { + + var capsule: Capsule + + init(capsule: Capsule) { + self.capsule = capsule + super.init(frame: .zero) + windowScene = AppContext.windowScene + switch capsule.vm.position { + case .top: + // 略高于toast + windowLevel = .phCapsuleTop + case .middle: + // 略低于alert + windowLevel = .phCapsuleMiddle + case .bottom: + // 略高于sheet + windowLevel = .phCapsuleBottom + } + frame = .init(x: 0, y: 0, width: 128, height: 48) + isHidden = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +extension Capsule { + var attachedWindow: CapsuleWindow? { + view.window as? CapsuleWindow + } +} diff --git a/Sources/ProHUD/Core/Models/Configuration.swift b/Sources/ProHUD/Core/Models/Configuration.swift index 0c60913..9e31419 100644 --- a/Sources/ProHUD/Core/Models/Configuration.swift +++ b/Sources/ProHUD/Core/Models/Configuration.swift @@ -106,13 +106,11 @@ public class Configuration: NSObject { // MARK: 文本样式 - var customTextLabel: ((_ label: UILabel) -> Void)? = { label in - label.font = .boldSystemFont(ofSize: 18) - } + var customTextLabel: ((_ label: UILabel) -> Void)? /// 自定义文本标签(标题或正文) /// - Parameter handler: 自定义文本标签(标题或正文) - public func customTextLabel(handler: @escaping (_ label: UILabel) -> Void) { + public func customTextLabel(_ handler: @escaping (_ label: UILabel) -> Void) { customTextLabel = handler } @@ -120,7 +118,7 @@ public class Configuration: NSObject { /// 自定义标题标签 /// - Parameter handler: 自定义标题标签 - public func customTitleLabel(handler: @escaping (_ label: UILabel) -> Void) { + public func customTitleLabel(_ handler: @escaping (_ label: UILabel) -> Void) { customTitleLabel = handler } @@ -129,7 +127,7 @@ public class Configuration: NSObject { /// 自定义正文标签 /// - Parameter handler: 自定义正文标签 - public func customBodyLabel(handler: @escaping (_ label: UILabel) -> Void) { + public func customBodyLabel(_ handler: @escaping (_ label: UILabel) -> Void) { customBodyLabel = handler } diff --git a/Sources/ProHUD/Core/Models/ViewModel.swift b/Sources/ProHUD/Core/Models/ViewModel.swift index 474424f..0da5b83 100644 --- a/Sources/ProHUD/Core/Models/ViewModel.swift +++ b/Sources/ProHUD/Core/Models/ViewModel.swift @@ -30,6 +30,10 @@ open class ViewModel: NSObject { } } + public required override init() { + + } + public convenience init(icon: UIImage? = nil, duration: TimeInterval? = nil) { self.init() self.icon = icon @@ -62,83 +66,95 @@ open class ViewModel: NSObject { // MARK: - convenience func public extension ViewModel { - func icon(_ image: UIImage?) -> ViewModel { + func icon(_ image: UIImage?) -> Self { self.icon = image return self } - func icon(_ imageURL: URL?) -> ViewModel { + func icon(_ imageURL: URL?) -> Self { self.iconURL = imageURL return self } - - func title(_ text: String?) -> ViewModel { + func title(_ text: String?) -> Self { self.title = text return self } - func message(_ text: String?) -> ViewModel { + func message(_ text: String?) -> Self { self.message = text return self } - func duration(_ seconds: TimeInterval?) -> ViewModel { + func duration(_ seconds: TimeInterval?) -> Self { self.duration = seconds return self } + func rotation(_ rotation: Rotation?) -> Self { + self.rotation = rotation + return self + } + } // MARK: - example scenes public extension ViewModel { // MARK: plain - static func title(_ text: String?) -> ViewModel { - let obj = ViewModel() - obj.title = text - return obj + static func title(_ text: String?) -> Self { + .init() + .title(text) } - static func message(_ text: String?) -> ViewModel { - let obj = ViewModel() - obj.message = text - return obj + static func message(_ text: String?) -> Self { + .init() + .message(text) } // MARK: loading - static var loading: ViewModel { - let obj = ViewModel(icon: UIImage(inProHUD: "prohud.windmill")) - obj.rotation = .init(repeatCount: .infinity) - return obj + static var loading: Self { + .init() + .icon(.init(inProHUD: "prohud.windmill")) + .rotation(.init(repeatCount: .infinity)) } - static func loading(_ seconds: TimeInterval) -> ViewModel { - let obj = ViewModel(icon: UIImage(inProHUD: "prohud.windmill"), duration: seconds) - obj.rotation = .init(repeatCount: .infinity) - return obj + static func loading(_ seconds: TimeInterval) -> Self { + .init() + .icon(.init(inProHUD: "prohud.windmill")) + .rotation(.init(repeatCount: .infinity)) + .duration(seconds) } // MARK: success - static var success: ViewModel { - .init(icon: UIImage(inProHUD: "prohud.checkmark")) + static var success: Self { + .init() + .icon(.init(inProHUD: "prohud.checkmark")) } - static func success(_ seconds: TimeInterval) -> ViewModel { - .init(icon: UIImage(inProHUD: "prohud.checkmark"), duration: seconds) + static func success(_ seconds: TimeInterval) -> Self { + .init() + .icon(.init(inProHUD: "prohud.checkmark")) + .duration(seconds) } // MARK: warning - static var warning: ViewModel { - .init(icon: UIImage(inProHUD: "prohud.exclamationmark")) + static var warning: Self { + .init() + .icon(.init(inProHUD: "prohud.exclamationmark")) } - static func warning(_ seconds: TimeInterval) -> ViewModel { - .init(icon: UIImage(inProHUD: "prohud.exclamationmark"), duration: seconds) + static func warning(_ seconds: TimeInterval) -> Self { + .init() + .icon(.init(inProHUD: "prohud.exclamationmark")) + .duration(seconds) } // MARK: error - static var error: ViewModel { - .init(icon: UIImage(inProHUD: "prohud.xmark")) + static var error: Self { + .init() + .icon(.init(inProHUD: "prohud.xmark")) } - static func error(_ seconds: TimeInterval) -> ViewModel { - .init(icon: UIImage(inProHUD: "prohud.xmark"), duration: seconds) + static func error(_ seconds: TimeInterval) -> Self { + .init() + .icon(.init(inProHUD: "prohud.xmark")) + .duration(seconds) } // MARK: failure - static var failure: ViewModel { error } - static func failure(_ seconds: TimeInterval) -> ViewModel { error(seconds) } + static var failure: Self { error } + static func failure(_ seconds: TimeInterval) -> Self { error(seconds) } } diff --git a/Sources/ProHUD/Core/Utils/AppContext.swift b/Sources/ProHUD/Core/Utils/AppContext.swift index 07ca7b5..cb14581 100644 --- a/Sources/ProHUD/Core/Utils/AppContext.swift +++ b/Sources/ProHUD/Core/Utils/AppContext.swift @@ -36,6 +36,7 @@ public struct AppContext { static var toastWindows: [UIWindowScene: [ToastWindow]] = [:] static var alertWindow: [UIWindowScene: AlertWindow] = [:] static var sheetWindows: [UIWindowScene: [SheetWindow]] = [:] + static var capsuleWindows: [UIWindowScene: [Capsule.CapsuleViewModel.Position: CapsuleWindow]] = [:] static var current: AppContext? { guard let windowScene = windowScene else { return nil } @@ -116,10 +117,11 @@ extension AppContext { var sheetWindows: [SheetWindow] { Self.sheetWindows[windowScene] ?? [] } -} - -extension AppContext { var toastWindows: [ToastWindow] { Self.toastWindows[windowScene] ?? [] } + var capsuleWindows: [Capsule.CapsuleViewModel.Position: CapsuleWindow] { + Self.capsuleWindows[windowScene] ?? [:] + } } + diff --git a/Sources/ProHUD/Core/Utils/Utils.swift b/Sources/ProHUD/Core/Utils/Utils.swift index 3b4d96e..ed11c13 100644 --- a/Sources/ProHUD/Core/Utils/Utils.swift +++ b/Sources/ProHUD/Core/Utils/Utils.swift @@ -30,3 +30,21 @@ var isPortrait: Bool { var isPhonePortrait: Bool { UIDevice.current.userInterfaceIdiom == .phone && (AppContext.windowScene?.interfaceOrientation.isPortrait == true) } + + +// 层级: Capsule(top) -> Toast -> 原生Alert -> Alert -> Capsule(middle) -> Sheet -> Capsule(bottom) +extension UIWindow.Level { + + public static let phCapsuleTop: UIWindow.Level = .init(rawValue: UIWindow.Level.alert.rawValue + 1005) + + public static let phToast: UIWindow.Level = .init(rawValue: UIWindow.Level.alert.rawValue + 1000) + + public static let phAlert: UIWindow.Level = .init(rawValue: UIWindow.Level.alert.rawValue - 10) + + public static let phCapsuleMiddle: UIWindow.Level = .init(rawValue: UIWindow.Level.alert.rawValue - 15) + + public static let phSheet: UIWindow.Level = .init(rawValue: UIWindow.Level.alert.rawValue - 20) + + public static let phCapsuleBottom: UIWindow.Level = .init(rawValue: UIWindow.Level.alert.rawValue - 25) + +} diff --git a/Sources/ProHUD/Core/Utils/ViewExts.swift b/Sources/ProHUD/Core/Utils/ViewExts.swift index 59a5300..092e4bd 100644 --- a/Sources/ProHUD/Core/Utils/ViewExts.swift +++ b/Sources/ProHUD/Core/Utils/ViewExts.swift @@ -10,7 +10,7 @@ import UIKit extension UIView { static func animateEaseOut(duration: TimeInterval, animations: @escaping () -> Void, completion: ((_ done: Bool) -> Void)? = nil) { - animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 1, options: [.allowUserInteraction, .curveEaseOut], animations: animations, completion: completion) + animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0.75, options: [.allowUserInteraction, .curveEaseOut], animations: animations, completion: completion) } } diff --git a/Sources/ProHUD/Sheet/Sheet.swift b/Sources/ProHUD/Sheet/Sheet.swift index d17de47..8ed9873 100644 --- a/Sources/ProHUD/Sheet/Sheet.swift +++ b/Sources/ProHUD/Sheet/Sheet.swift @@ -48,7 +48,7 @@ open class Sheet: Controller { handler(self) DispatchQueue.main.async { - SheetWindow.push(sheet: self) + self.push() } } @@ -80,7 +80,7 @@ extension Sheet { if let act = onTappedBackground { act(self) } else { - SheetWindow.pop(sheet: self) + self.pop() } } } diff --git a/Sources/ProHUD/Sheet/SheetManager.swift b/Sources/ProHUD/Sheet/SheetManager.swift index 6c94061..037718b 100644 --- a/Sources/ProHUD/Sheet/SheetManager.swift +++ b/Sources/ProHUD/Sheet/SheetManager.swift @@ -11,11 +11,50 @@ extension Sheet: HUD { @objc open func push() { guard Configuration.isEnabled else { return } - SheetWindow.push(sheet: self) + let isNew: Bool + let window: SheetWindow + var windows = AppContext.current?.sheetWindows ?? [] + if let w = windows.first(where: { $0.sheet == self }) { + isNew = false + window = w + } else { + window = SheetWindow(sheet: self) + isNew = true + } + window.rootViewController = self + if windows.contains(window) == false { + windows.append(window) + setContextWindows(windows) + } + if isNew { + navEvents[.onViewWillAppear]?(self) + window.sheet.translateIn { [weak self] in + guard let self = self else { return } + self.navEvents[.onViewDidAppear]?(self) + } + } else { + view.layoutIfNeeded() + } } @objc open func pop() { - SheetWindow.pop(sheet: self) + var windows = getContextWindows() + guard let window = windows.first(where: { $0.sheet == self }) else { + return + } + navEvents[.onViewWillDisappear]?(self) + window.sheet.translateOut { [weak window, weak self] in + guard let self = self, let win = window else { return } + win.sheet.navEvents[.onViewDidDisappear]?(win.sheet) + if windows.count > 1 { + windows.removeAll { $0 == win } + } else if windows.count == 1 { + windows.removeAll() + } else { + consolePrint("‼️代码漏洞:已经没有sheet了") + } + self.setContextWindows(windows) + } } } diff --git a/Sources/ProHUD/Sheet/SheetWindow.swift b/Sources/ProHUD/Sheet/SheetWindow.swift index ba3f625..def4164 100644 --- a/Sources/ProHUD/Sheet/SheetWindow.swift +++ b/Sources/ProHUD/Sheet/SheetWindow.swift @@ -7,22 +7,6 @@ import UIKit - -private extension Sheet { - func getContextWindows() -> [SheetWindow] { - guard let windowScene = windowScene else { - return [] - } - return AppContext.sheetWindows[windowScene] ?? [] - } - func setContextWindows(_ windows: [SheetWindow]) { - guard let windowScene = windowScene else { - return - } - AppContext.sheetWindows[windowScene] = windows - } -} - class SheetWindow: Window { var sheet: Sheet @@ -35,7 +19,7 @@ class SheetWindow: Window { super.init(frame: AppContext.appBounds) } sheet.window = self - windowLevel = .init(rawValue: UIWindow.Level.alert.rawValue - 2) + windowLevel = .phSheet isHidden = false } @@ -43,52 +27,19 @@ class SheetWindow: Window { fatalError("init(coder:) has not been implemented") } - static func push(sheet: Sheet) { - let isNew: Bool - let window: SheetWindow - var windows = AppContext.current?.sheetWindows ?? [] - if let w = windows.first(where: { $0.sheet == sheet }) { - isNew = false - window = w - } else { - window = SheetWindow(sheet: sheet) - isNew = true - } - window.rootViewController = sheet - if windows.contains(window) == false { - windows.append(window) - sheet.setContextWindows(windows) - } - if isNew { - sheet.navEvents[.onViewWillAppear]?(sheet) - window.sheet.translateIn { - sheet.navEvents[.onViewDidAppear]?(sheet) - } - } else { - sheet.view.layoutIfNeeded() +} + +extension Sheet { + func getContextWindows() -> [SheetWindow] { + guard let windowScene = windowScene else { + return [] } + return AppContext.sheetWindows[windowScene] ?? [] } - - static func pop(sheet: Sheet) { - var windows = sheet.getContextWindows() - guard let window = windows.first(where: { $0.sheet == sheet }) else { + func setContextWindows(_ windows: [SheetWindow]) { + guard let windowScene = windowScene else { return } - sheet.navEvents[.onViewWillDisappear]?(sheet) - window.sheet.translateOut { [weak window] in - if let win = window { - win.sheet.navEvents[.onViewDidDisappear]?(win.sheet) - if windows.count > 1 { - windows.removeAll { $0 == win } - } else if windows.count == 1 { - windows.removeAll() - } else { - consolePrint("‼️代码漏洞:已经没有sheet了") - } - sheet.setContextWindows(windows) - } - } + AppContext.sheetWindows[windowScene] = windows } - - } diff --git a/Sources/ProHUD/Toast/Toast.swift b/Sources/ProHUD/Toast/Toast.swift index ea6254a..bcb838c 100644 --- a/Sources/ProHUD/Toast/Toast.swift +++ b/Sources/ProHUD/Toast/Toast.swift @@ -19,7 +19,7 @@ open class Toast: Controller { public var progressView: ProgressView? - /// 内容容器(包括icon、textStack、actionStack) + /// 内容容器(包括infoStack、actionStack) public lazy var contentStack: StackView = { let stack = StackView(axis: .vertical) stack.spacing = 16 @@ -27,7 +27,7 @@ open class Toast: Controller { return stack }() - /// 信息容器(image+text) + /// 信息容器(imageView+textStack) public lazy var infoStack: StackView = { let stack = StackView(axis: .horizontal) stack.spacing = 8 @@ -36,7 +36,7 @@ open class Toast: Controller { return stack }() - /// 文本容器 + /// 文本容器(title、body) public lazy var textStack: StackView = { let stack = StackView(axis: .vertical) stack.spacing = config.lineSpace @@ -96,21 +96,19 @@ open class Toast: Controller { } - @discardableResult public init(_ vm: ViewModel?, handler: ((_ toast: Toast) -> Void)? = nil) { + @discardableResult public init(_ vm: ViewModel, handler: ((_ toast: Toast) -> Void)? = nil) { super.init() - if let vm = vm { - self.vm = vm - } + self.vm = vm handler?(self) DispatchQueue.main.async { if handler != nil { - ToastWindow.push(toast: self) + self.push() } } } @discardableResult public convenience init(handler: ((_ toast: Toast) -> Void)?) { - self.init(nil, handler: handler) + self.init(.init(), handler: handler) } required public init?(coder: NSCoder) { diff --git a/Sources/ProHUD/Toast/ToastDefaultLayout.swift b/Sources/ProHUD/Toast/ToastDefaultLayout.swift index d1696fe..b98e719 100644 --- a/Sources/ProHUD/Toast/ToastDefaultLayout.swift +++ b/Sources/ProHUD/Toast/ToastDefaultLayout.swift @@ -48,7 +48,11 @@ extension Toast: DefaultLayout { if bodyCount > 0 { config.customTitleLabel?(titleLabel) } else { - config.customTextLabel?(bodyLabel) + if let customTextLabel = config.customTextLabel { + customTextLabel(titleLabel) + } else { + titleLabel.font = .boldSystemFont(ofSize: 18) + } } } else { if textStack.arrangedSubviews.contains(titleLabel) { @@ -61,7 +65,11 @@ extension Toast: DefaultLayout { if titleCount > 0 { config.customBodyLabel?(bodyLabel) } else { - config.customTextLabel?(bodyLabel) + if let customTextLabel = config.customTextLabel { + customTextLabel(bodyLabel) + } else { + bodyLabel.font = .boldSystemFont(ofSize: 18) + } } } else { if textStack.arrangedSubviews.contains(bodyLabel) { diff --git a/Sources/ProHUD/Toast/ToastManager.swift b/Sources/ProHUD/Toast/ToastManager.swift index dec8706..c5eea55 100644 --- a/Sources/ProHUD/Toast/ToastManager.swift +++ b/Sources/ProHUD/Toast/ToastManager.swift @@ -9,13 +9,99 @@ import UIKit extension Toast: HUD { + private func calcHeight() -> CGFloat { + var height = CGFloat(0) + for v in infoStack.arrangedSubviews { + // 图片或者文本最大高度 + height = CGFloat.maximum(v.frame.maxY, height) + } + if actionStack.arrangedSubviews.count > 0 { + height += actionStack.frame.height + contentStack.spacing + } + contentView.subviews.filter { v in + if v == contentMaskView { + return false + } + if v == contentStack { + return false + } + return true + } .forEach { v in + height = CGFloat.maximum(v.frame.maxY, height) + } + // 上下边间距 + let cardEdgeInsets = config.cardEdgeInsetsByDefault + height += cardEdgeInsets.top + cardEdgeInsets.bottom + return height + } @objc open func push() { guard Configuration.isEnabled else { return } - ToastWindow.push(toast: self) + let isNew: Bool + let window: ToastWindow + var windows = AppContext.current?.toastWindows ?? [] + if let w = windows.first(where: { $0.toast == self }) { + isNew = false + window = w + } else { + window = ToastWindow(toast: self) + isNew = true + } + + // frame + let cardEdgeInsets = config.cardEdgeInsetsByDefault + let width = CGFloat.minimum(AppContext.appBounds.width - config.windowEdgeInsetByDefault - config.windowEdgeInsetByDefault, config.cardMaxWidthByDefault) + view.frame.size = CGSize(width: width, height: config.cardMaxHeightByDefault) + titleLabel.sizeToFit() + bodyLabel.sizeToFit() + view.layoutIfNeeded() + // 更新子视图之后获取正确的高度 + let height = calcHeight() + view.frame.size = CGSize(width: width, height: height) + // 应用到frame + window.frame = CGRect(x: (AppContext.appBounds.width - width) / 2, y: 0, width: width, height: height) + window.rootViewController = self // 此时toast.view.frame.size会自动更新为window.frame.size + if windows.contains(window) == false { + windows.append(window) + setContextWindows(windows) + } + ToastWindow.updateToastWindowsLayout(windows: windows) + if isNew { + window.transform = .init(translationX: 0, y: -window.frame.maxY) + UIView.animateEaseOut(duration: config.animateDurationForBuildInByDefault) { + window.transform = .identity + } completion: { done in + self.navEvents[.onViewDidAppear]?(self) + } + } else { + view.layoutIfNeeded() + self.navEvents[.onViewDidAppear]?(self) + } } @objc open func pop() { - ToastWindow.pop(toast: self) + var windows = getContextWindows() + guard let window = windows.first(where: { $0.toast == self }) else { + return + } + if windows.count > 1 { + windows.removeAll { $0 == window } + ToastWindow.updateToastWindowsLayout(windows: windows) + } else if windows.count == 1 { + windows.removeAll() + } else { + consolePrint("‼️代码漏洞:已经没有toast了") + } + vm.duration = nil + setContextWindows(windows) + UIView.animateEaseOut(duration: config.animateDurationForBuildOutByDefault) { + window.transform = .init(translationX: 0, y: 0-20-window.maxY) + } completion: { done in + self.view.removeFromSuperview() + self.removeFromParent() + self.navEvents[.onViewDidDisappear]?(self) + // 这里设置一下window属性,会使window的生命周期被延长到此处,即动画执行过程中window不会被提前释放 + window.isHidden = true + } } } @@ -60,3 +146,41 @@ public extension Toast { } } + +// MARK: - layout + +fileprivate var updateToastsLayoutWorkItem: DispatchWorkItem? + +fileprivate extension ToastWindow { + + static func setToastWindowsLayout(windows: [ToastWindow]) { + for (i, window) in windows.enumerated() { + let config = window.toast.config + var y = window.frame.origin.y + if i == 0 { + let topLayoutMargins = AppContext.appWindow?.layoutMargins.top ?? config.margin + y = max(topLayoutMargins - config.margin, config.margin) + } else { + if i - 1 < windows.count && i > 0 { + y = config.margin + windows[i-1].frame.maxY + } else { + y = config.margin + } + } + window.maxY = y + window.frame.size.height + UIView.animateEaseOut(duration: config.animateDurationForReloadByDefault) { + window.frame.origin.y = y + } + } + } + + static func updateToastWindowsLayout(windows: [ToastWindow]) { + updateToastsLayoutWorkItem?.cancel() + updateToastsLayoutWorkItem = DispatchWorkItem { + setToastWindowsLayout(windows: windows) + updateToastsLayoutWorkItem = nil + } + DispatchQueue.main.asyncAfter(deadline: .now()+0.001, execute: updateToastsLayoutWorkItem!) + } + +} diff --git a/Sources/ProHUD/Toast/ToastWindow.swift b/Sources/ProHUD/Toast/ToastWindow.swift index eca1042..68a336f 100644 --- a/Sources/ProHUD/Toast/ToastWindow.swift +++ b/Sources/ProHUD/Toast/ToastWindow.swift @@ -7,21 +7,6 @@ import UIKit -private extension Toast { - func getContextWindows() -> [ToastWindow] { - guard let windowScene = windowScene else { - return [] - } - return AppContext.toastWindows[windowScene] ?? [] - } - func setContextWindows(_ windows: [ToastWindow]) { - guard let windowScene = windowScene else { - return - } - AppContext.toastWindows[windowScene] = windows - } -} - class ToastWindow: Window { var toast: Toast @@ -33,7 +18,7 @@ class ToastWindow: Window { super.init(frame: .zero) windowScene = AppContext.windowScene toast.window = self - windowLevel = .init(rawValue: UIWindow.Level.alert.rawValue + 1000) + windowLevel = .phToast layer.shadowRadius = 8 layer.shadowOffset = .init(width: 0, height: 5) layer.shadowOpacity = 0.2 @@ -49,139 +34,19 @@ class ToastWindow: Window { layer.shadowPath = UIBezierPath.init(rect: bounds).cgPath } - static func push(toast: Toast) { - let isNew: Bool - let window: ToastWindow - var windows = AppContext.current?.toastWindows ?? [] - if let w = windows.first(where: { $0.toast == toast }) { - isNew = false - window = w - } else { - window = ToastWindow(toast: toast) - isNew = true - } - let config = toast.config - - // frame - let cardEdgeInsets = config.cardEdgeInsetsByDefault - let width = CGFloat.minimum(AppContext.appBounds.width - config.windowEdgeInsetByDefault - config.windowEdgeInsetByDefault, config.cardMaxWidthByDefault) - toast.view.frame.size = CGSize(width: width, height: config.cardMaxHeightByDefault) - toast.titleLabel.sizeToFit() - toast.bodyLabel.sizeToFit() - toast.view.layoutIfNeeded() - // 更新子视图之后获取正确的高度 - let height = toast.calcHeight() - toast.view.frame.size = CGSize(width: width, height: height) - // 应用到frame - window.frame = CGRect(x: (AppContext.appBounds.width - width) / 2, y: 0, width: width, height: height) - window.rootViewController = toast // 此时toast.view.frame.size会自动更新为window.frame.size - if windows.contains(window) == false { - windows.append(window) - toast.setContextWindows(windows) - } - updateToastWindowsLayout(windows: windows) - if isNew { - window.transform = .init(translationX: 0, y: -window.frame.maxY) - UIView.animateEaseOut(duration: config.animateDurationForBuildInByDefault) { - window.transform = .identity - } completion: { done in - toast.navEvents[.onViewDidAppear]?(toast) - } - } else { - toast.view.layoutIfNeeded() - toast.navEvents[.onViewDidAppear]?(toast) +} + +extension Toast { + func getContextWindows() -> [ToastWindow] { + guard let windowScene = windowScene else { + return [] } + return AppContext.toastWindows[windowScene] ?? [] } - - static func pop(toast: Toast) { - var windows = toast.getContextWindows() - guard let window = windows.first(where: { $0.toast == toast }) else { + func setContextWindows(_ windows: [ToastWindow]) { + guard let windowScene = windowScene else { return } - if windows.count > 1 { - windows.removeAll { $0 == window } - updateToastWindowsLayout(windows: windows) - } else if windows.count == 1 { - windows.removeAll() - } else { - consolePrint("‼️代码漏洞:已经没有toast了") - } - toast.vm.duration = nil - toast.setContextWindows(windows) - UIView.animateEaseOut(duration: toast.config.animateDurationForBuildOutByDefault) { - window.transform = .init(translationX: 0, y: 0-20-window.maxY) - } completion: { done in - toast.view.removeFromSuperview() - toast.removeFromParent() - toast.navEvents[.onViewDidDisappear]?(toast) - // 这里设置一下window属性,会使window的生命周期被延长到此处,即动画执行过程中window不会被提前释放 - window.isHidden = true - } - } - - -} - -fileprivate var updateToastsLayoutWorkItem: DispatchWorkItem? - -fileprivate extension ToastWindow { - - static func setToastWindowsLayout(windows: [ToastWindow]) { - for (i, window) in windows.enumerated() { - let config = window.toast.config - var y = window.frame.origin.y - if i == 0 { - let topLayoutMargins = AppContext.appWindow?.layoutMargins.top ?? config.margin - y = max(topLayoutMargins - config.margin, config.margin) - } else { - if i - 1 < windows.count && i > 0 { - y = config.margin + windows[i-1].frame.maxY - } else { - y = config.margin - } - } - window.maxY = y + window.frame.size.height - UIView.animateEaseOut(duration: config.animateDurationForReloadByDefault) { - window.frame.origin.y = y - } - } - } - - static func updateToastWindowsLayout(windows: [ToastWindow]) { - updateToastsLayoutWorkItem?.cancel() - updateToastsLayoutWorkItem = DispatchWorkItem { - setToastWindowsLayout(windows: windows) - updateToastsLayoutWorkItem = nil - } - DispatchQueue.main.asyncAfter(deadline: .now()+0.001, execute: updateToastsLayoutWorkItem!) - } - -} - -fileprivate extension Toast { - func calcHeight() -> CGFloat { - var height = CGFloat(0) - for v in infoStack.arrangedSubviews { - // 图片或者文本最大高度 - height = CGFloat.maximum(v.frame.maxY, height) - } - if actionStack.arrangedSubviews.count > 0 { - height += actionStack.frame.height + contentStack.spacing - } - contentView.subviews.filter { v in - if v == contentMaskView { - return false - } - if v == contentStack { - return false - } - return true - } .forEach { v in - height = CGFloat.maximum(v.frame.maxY, height) - } - // 上下边间距 - let cardEdgeInsets = config.cardEdgeInsetsByDefault - height += cardEdgeInsets.top + cardEdgeInsets.bottom - return height + AppContext.toastWindows[windowScene] = windows } }