From 92216c0c03a6e49f6ba389c111a6870a297f1d64 Mon Sep 17 00:00:00 2001 From: relikd Date: Wed, 1 Jul 2020 00:53:25 +0200 Subject: [PATCH] CustomAlert refactoring. Using proper UIPresentationController with adaptive margins --- AppCheck.xcodeproj/project.pbxproj | 4 + main/Common Classes/CustomAlert.swift | 151 ++++++++-------- main/Common Classes/QuickUI.swift | 4 + main/Common Classes/SlideInAnimation.swift | 190 +++++++++++++++++++++ main/Extensions/AutoLayout.swift | 15 ++ 5 files changed, 285 insertions(+), 79 deletions(-) create mode 100644 main/Common Classes/SlideInAnimation.swift diff --git a/AppCheck.xcodeproj/project.pbxproj b/AppCheck.xcodeproj/project.pbxproj index cb95a6b..8d54d1c 100644 --- a/AppCheck.xcodeproj/project.pbxproj +++ b/AppCheck.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 5404AEEB24A90717003B2F54 /* PrefsShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E67E4524A8B0FE0025D261 /* PrefsShared.swift */; }; + 5404AEED24A95F3F003B2F54 /* SlideInAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5404AEEC24A95F3F003B2F54 /* SlideInAnimation.swift */; }; 540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E677F242D2CF100871BBE /* VCRecordings.swift */; }; 540E67822433483D00871BBE /* VCEditRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67812433483D00871BBE /* VCEditRecording.swift */; }; 540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */; }; @@ -174,6 +175,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 5404AEEC24A95F3F003B2F54 /* SlideInAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideInAnimation.swift; sourceTree = ""; }; 540E677F242D2CF100871BBE /* VCRecordings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCRecordings.swift; sourceTree = ""; }; 540E67812433483D00871BBE /* VCEditRecording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCEditRecording.swift; sourceTree = ""; }; 540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCPreviousRecords.swift; sourceTree = ""; }; @@ -456,6 +458,7 @@ 54448A3124899A4000771C96 /* SearchBarManager.swift */, 549ECD9C24A7AD550097571C /* CustomAlert.swift */, 541FC47524A12D01009154D8 /* IBViews.swift */, + 5404AEEC24A95F3F003B2F54 /* SlideInAnimation.swift */, ); path = "Common Classes"; sourceTree = ""; @@ -901,6 +904,7 @@ 54B34594240E6343004C53CC /* TVCFilter.swift in Sources */, 549ECD9D24A7AD550097571C /* CustomAlert.swift in Sources */, 54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */, + 5404AEED24A95F3F003B2F54 /* SlideInAnimation.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/main/Common Classes/CustomAlert.swift b/main/Common Classes/CustomAlert.swift index 87552a6..7d113b2 100644 --- a/main/Common Classes/CustomAlert.swift +++ b/main/Common Classes/CustomAlert.swift @@ -6,19 +6,7 @@ class CustomAlert: UIViewController { private let alertDetail: String? private let customView: CustomView - private var callback: ((CustomView) -> Void)! - - private let backgroundShadow: UIView = { - let shadow = UIView() - shadow.autoresizingMask = [.flexibleWidth, .flexibleHeight] - return shadow - }() - - private let control: UIView = { - let x = UIView() - x.backgroundColor = .sysBackground - return x - }() + private var callback: ((CustomView) -> Void)? /// Default: `[Cancel, Save]` let buttonsBar: UIStackView = { @@ -41,56 +29,89 @@ class CustomAlert: UIViewController { alertDetail = detail customView = custom super.init(nibName: nil, bundle: nil) - modalPresentationStyle = .custom - if #available(iOS 13.0, *) { - isModalInPresentation = true + } + + override var isModalInPresentation: Bool { set{} get{true} } + override var modalPresentationStyle: UIModalPresentationStyle { set{} get{.custom} } + override var transitioningDelegate: UIViewControllerTransitioningDelegate? { + set {} get { + SlideInTransitioningDelegate(for: .bottom, modal: true) } } internal override func loadView() { - view = UIView() - view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - view.isHidden = true // otherwise control will flash on present + let control = UIView() + control.backgroundColor = .sysBackground + view = control - var h: CGFloat = 0 - var prevView: UIView? = nil - func appendView(_ x: UIView, top: CGFloat, lr: CGFloat) { - control.addSubview(x) - // sticky edges horizontally - x.anchor([.leading, .trailing], to: control, margin: lr) - chainPrevious(to: x.topAnchor, padding: top) - prevView = x - h += x.frame.height + top - } - func chainPrevious(to anchor: NSLayoutYAxisAnchor, padding p: CGFloat) { - anchor =&= (prevView?.bottomAnchor ?? control.topAnchor) + p/2 | .defaultLow - anchor =&= (prevView?.bottomAnchor ?? control.topAnchor) + p | .defaultHigh + var tmpPrevivous: UIView? = nil + + func adaptive(margin: CGFloat, _ fn: () -> NSLayoutConstraint) { + regularConstraints.append(fn() + margin) + compactConstraints.append(fn() + margin/2) } + func addLabel(_ lbl: UILabel) { + lbl.numberOfLines = 0 + control.addSubview(lbl) + lbl.anchor([.leading, .trailing], to: control.layoutMarginsGuide) + if let p = tmpPrevivous { + adaptive(margin: 16) { lbl.topAnchor =&= p.bottomAnchor } + } else { + adaptive(margin: 12) { lbl.topAnchor =&= control.layoutMarginsGuide.topAnchor } + } + tmpPrevivous = lbl + } + + // Alert title & description if let t = alertTitle { - let lbl = QuickUI.label(t, align: .center, style: .headline) - lbl.numberOfLines = 0 - appendView(lbl, top: 16, lr: 16) + let lbl = QuickUI.label(t, align: .center, style: .subheadline) + lbl.font = lbl.font.bold() + addLabel(lbl) } + if let d = alertDetail { - let lbl = QuickUI.label(d, align: .center, style: .subheadline) - lbl.numberOfLines = 0 - appendView(lbl, top: 16, lr: 16) + addLabel(QuickUI.label(d, align: .center, style: .footnote)) } - appendView(customView, top: (prevView == nil) ? 0 : 16, lr: 0) - appendView(buttonsBar, top: 0, lr: 25) - chainPrevious(to: control.bottomAnchor, padding: 15) - h += 15 // buttonsBar has 15px padding - let screen = UIScreen.main.bounds.size - control.frame = CGRect(x: 0, y: screen.height - h, width: screen.width, height: h) + // User content + control.addSubview(customView) + customView.anchor([.leading, .trailing], to: control) + if let p = tmpPrevivous { + customView.topAnchor =&= p.bottomAnchor | .defaultHigh + } else { + customView.topAnchor =&= control.layoutMarginsGuide.topAnchor + } - view.addSubview(control) - control.anchor([.leading, .trailing, .bottom], to: view!) - control.heightAnchor =<= view.heightAnchor + // Action buttons + control.addSubview(buttonsBar) + buttonsBar.anchor([.leading, .trailing], to: control.layoutMarginsGuide, margin: 8) + buttonsBar.topAnchor =&= customView.bottomAnchor | .defaultHigh + + adaptive(margin: 12) { control.layoutMarginsGuide.bottomAnchor =&= buttonsBar.bottomAnchor } + + adaptToNewTraits(traitCollection) + view.frame.size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) } + // MARK: - Adaptive Traits + + private var compactConstraints: [NSLayoutConstraint] = [] + private var regularConstraints: [NSLayoutConstraint] = [] + + private func adaptToNewTraits(_ traits: UITraitCollection) { + let flag = traits.verticalSizeClass == .compact + NSLayoutConstraint.deactivate(flag ? regularConstraints : compactConstraints) + NSLayoutConstraint.activate(flag ? compactConstraints : regularConstraints) + view.setNeedsLayout() + } + + override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) { + super.willTransition(to: newCollection, with: coordinator) + adaptToNewTraits(newCollection) + } + // MARK: - User Interaction override var keyCommands: [UIKeyCommand]? { @@ -104,7 +125,7 @@ class CustomAlert: UIViewController { @objc private func didTapSave() { dismiss(animated: true) { - self.callback(self.customView) + self.callback?(self.customView) self.callback = nil } } @@ -114,26 +135,7 @@ class CustomAlert: UIViewController { func present(in viewController: UIViewController, onSuccess: @escaping (CustomView) -> Void) { callback = onSuccess - loadViewIfNeeded() - viewController.present(self, animated: false) { - let prev = self.control.frame.origin.y - self.control.frame.origin.y += self.control.frame.height - self.view.isHidden = false - - UIView.animate(withDuration: 0.3) { - self.view.backgroundColor = UIColor.black.withAlphaComponent(0.5) - self.control.frame.origin.y = prev - } - } - } - - override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { - UIView.animate(withDuration: 0.3, animations: { - self.view.backgroundColor = .clear - self.control.frame.origin.y += self.control.frame.height - }) { _ in - super.dismiss(animated: false, completion: completion) - } + viewController.present(self, animated: true) } } @@ -145,11 +147,7 @@ class CustomAlert: UIViewController { class DatePickerAlert : CustomAlert { - let datePicker: UIDatePicker = { - let x = UIDatePicker() - x.frame.size.height = x.sizeThatFits(.zero).height - return x - }() + let datePicker = UIDatePicker() required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -184,14 +182,9 @@ class DatePickerAlert : CustomAlert { class DurationPickerAlert: CustomAlert, UIPickerViewDataSource, UIPickerViewDelegate { + let pickerView = UIPickerView() private let dataSource: [[String]] private let compWidths: [CGFloat] - let pickerView: UIPickerView = { - let x = UIPickerView() - x.frame.size.height = x.sizeThatFits(.zero).height - return x - }() - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/main/Common Classes/QuickUI.swift b/main/Common Classes/QuickUI.swift index 184360f..8405fbf 100644 --- a/main/Common Classes/QuickUI.swift +++ b/main/Common Classes/QuickUI.swift @@ -7,6 +7,9 @@ struct QuickUI { x.text = str x.textAlignment = align x.font = .preferredFont(forTextStyle: style) + x.constrainHuggingCompression(.horizontal, .defaultLow) + x.constrainHuggingCompression(.vertical, .defaultHigh) + x.sizeToFit() if #available(iOS 10.0, *) { x.adjustsFontForContentSizeCategory = true } @@ -17,6 +20,7 @@ struct QuickUI { let x = UIButton(type: .roundedRect) x.setTitle(title, for: .normal) x.titleLabel?.font = .preferredFont(forTextStyle: .body) + x.constrainHuggingCompression(.vertical, .defaultHigh) x.sizeToFit() if let a = action { x.addTarget(target, action: a, for: .touchUpInside) } if #available(iOS 10.0, *) { diff --git a/main/Common Classes/SlideInAnimation.swift b/main/Common Classes/SlideInAnimation.swift new file mode 100644 index 0000000..a5d3c19 --- /dev/null +++ b/main/Common Classes/SlideInAnimation.swift @@ -0,0 +1,190 @@ +import UIKit + +enum PresentationEdge { case left, top, right, bottom } + +// ######################################## +// # +// # MARK: - Transitioning Delegate +// # +// ######################################## + +class SlideInTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate { + private var edge: PresentationEdge + private var modal: Bool + private var dismissable: Bool + private var shadow: UIColor? + + init(for edge: PresentationEdge, modal: Bool, tapAnywhereToDismiss: Bool = false, modalBackgroundColor color: UIColor? = nil) { + self.edge = edge + self.dismissable = tapAnywhereToDismiss + self.shadow = color + self.modal = modal + } + + func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { + StickyPresentationController(presented: presented, presenting: presenting, stickTo: edge, modal: modal, tapAnywhereToDismiss: dismissable, modalBackgroundColor: shadow) + } + + func animationController(forPresented _: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + SlideInAnimationController(from: edge, isPresentation: true) + } + + func animationController(forDismissed _: UIViewController) -> UIViewControllerAnimatedTransitioning? { + SlideInAnimationController(from: edge, isPresentation: false) + } +} + +// ######################################## +// # +// # MARK: - Animated Transitioning +// # +// ######################################## + +private final class SlideInAnimationController: NSObject, UIViewControllerAnimatedTransitioning { + let edge: PresentationEdge + let appear: Bool + + init(from edge: PresentationEdge, isPresentation: Bool) { + self.edge = edge + self.appear = isPresentation + super.init() + } + + func transitionDuration(using context: UIViewControllerContextTransitioning?) -> TimeInterval { + (context?.isAnimated ?? true) ? 0.3 : 0.0 + } + + func animateTransition(using context: UIViewControllerContextTransitioning) { + guard let vc = context.viewController(forKey: appear ? .to : .from) else { return } + + var to = context.finalFrame(for: vc) + var from = to + switch edge { + case .left: from.origin.x = -to.width + case .right: from.origin.x = context.containerView.frame.width + case .top: from.origin.y = -to.height + case .bottom: from.origin.y = context.containerView.frame.height + } + + if appear { context.containerView.addSubview(vc.view) } + else { swap(&from, &to) } + + vc.view.frame = from + UIView.animate(withDuration: transitionDuration(using: context), animations: { + vc.view.frame = to + }, completion: { finished in + if !self.appear { vc.view.removeFromSuperview() } + context.completeTransition(finished) + }) + } +} + +// ######################################### +// # +// # MARK: - Presentation Controller +// # +// ######################################### + +private class StickyPresentationController: UIPresentationController { + private let stickTo: PresentationEdge + private let isModal: Bool + + private let bg = UIView() + private var availableSize: CGSize = .zero // save original size when resizing the container + + override var shouldPresentInFullscreen: Bool { false } + override var frameOfPresentedViewInContainerView: CGRect { fittedContentFrame() } + + required init(presented: UIViewController, presenting: UIViewController?, stickTo edge: PresentationEdge, modal: Bool = true, tapAnywhereToDismiss: Bool = false, modalBackgroundColor bgColor: UIColor? = nil) { + self.stickTo = edge + self.isModal = modal + super.init(presentedViewController: presented, presenting: presenting) + bg.backgroundColor = bgColor ?? .init(white: 0, alpha: 0.5) + if modal, tapAnywhereToDismiss { + bg.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(didTapBackground)) + ) + } + } + + // MARK: Present + + override func presentationTransitionWillBegin() { + availableSize = containerView!.frame.size + + guard isModal else { return } + containerView!.insertSubview(bg, at: 0) + bg.alpha = 0.0 + if presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in + self.bg.alpha = 1.0 + }) != true { bg.alpha = 1.0 } + } + + @objc func didTapBackground(_ sender: UITapGestureRecognizer) { + presentingViewController.dismiss(animated: true) + } + + // MARK: Dismiss + + override func dismissalTransitionWillBegin() { + if presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in + self.bg.alpha = 0.0 + }) != true { bg.alpha = 0.0 } + } + + override func dismissalTransitionDidEnd(_ completed: Bool) { + if completed { bg.removeFromSuperview() } + } + + // MARK: Update + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + availableSize = size + super.viewWillTransition(to: size, with: coordinator) + } + + override func containerViewDidLayoutSubviews() { + super.containerViewDidLayoutSubviews() + bg.frame = containerView!.bounds + if isModal { + presentedView!.frame = fittedContentFrame() + } else { + containerView!.frame = fittedContentFrame() + presentedView!.frame = containerView!.bounds + } + } + + /// Calculate `fittedContentSize()` then offset frame to sticky edge respecting *available* container size . + func fittedContentFrame() -> CGRect { + var frame = CGRect(origin: .zero, size: fittedContentSize()) + switch stickTo { + case .right: frame.origin.x = availableSize.width - frame.width + case .bottom: frame.origin.y = availableSize.height - frame.height + default: break + } + return frame + } + + /// Calculate best fitting size for available container size and presentation sticky edge. + func fittedContentSize() -> CGSize { + guard let target = presentedView else { return availableSize } + let full = availableSize + let preferred = presentedViewController.preferredContentSize + switch stickTo { + case .left, .right: + let fitted = target.systemLayoutSizeFitting( + CGSize(width: preferred.width, height: full.height), + withHorizontalFittingPriority: .fittingSizeLevel, + verticalFittingPriority: .required + ) + return CGSize(width: min(fitted.width, full.width), height: full.height) + case .top, .bottom: + let fitted = target.systemLayoutSizeFitting( + CGSize(width: full.width, height: preferred.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + return CGSize(width: full.width, height: min(fitted.height, full.height)) + } + } +} diff --git a/main/Extensions/AutoLayout.swift b/main/Extensions/AutoLayout.swift index 7c9b772..bdb799f 100644 --- a/main/Extensions/AutoLayout.swift +++ b/main/Extensions/AutoLayout.swift @@ -47,6 +47,15 @@ extension NSLayoutConstraint { @discardableResult static func |(l: NSLayoutConstraint, r: UILayoutPriority) -> NSLayoutConstraint { l.priority = r; return l } } +extension NSLayoutDimension { + /// Create and activate an `equal` constraint with constant value. Format: `A.anchor =&= constant | priority` + @discardableResult static func =&= (l: NSLayoutDimension, r: CGFloat) -> NSLayoutConstraint { l.constraint(equalToConstant: r).on() } + /// Create and activate a `lessThan` constraint with constant value. Format: `A.anchor =<= constant | priority` + @discardableResult static func =<= (l: NSLayoutDimension, r: CGFloat) -> NSLayoutConstraint { l.constraint(lessThanOrEqualToConstant: r).on() } + /// Create and activate a `greaterThan` constraint with constant value. Format: `A.anchor =>= constant | priority` + @discardableResult static func =>= (l: NSLayoutDimension, r: CGFloat) -> NSLayoutConstraint { l.constraint(greaterThanOrEqualToConstant: r).on() } +} + /* UIView extension to generate multiple constraints at once @@ -73,6 +82,12 @@ extension UIView { return NSLayoutConstraint(item: A, attribute: $0, relatedBy: rel, toItem: B, attribute: $0, multiplier: 1, constant: margin).on() } } + + /// Sets the priority with which a view resists being made smaller and larger than its intrinsic size. + func constrainHuggingCompression(_ axis: NSLayoutConstraint.Axis, _ priotity: UILayoutPriority) { + setContentHuggingPriority(priotity, for: axis) + setContentCompressionResistancePriority(priotity, for: axis) + } } extension Array where Element: NSLayoutConstraint {