CustomAlert refactoring. Using proper UIPresentationController with adaptive margins

This commit is contained in:
relikd
2020-07-01 00:53:25 +02:00
parent 9ece3474c6
commit 92216c0c03
5 changed files with 285 additions and 79 deletions

View File

@@ -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 = "<group>"; };
540E677F242D2CF100871BBE /* VCRecordings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCRecordings.swift; sourceTree = "<group>"; };
540E67812433483D00871BBE /* VCEditRecording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCEditRecording.swift; sourceTree = "<group>"; };
540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCPreviousRecords.swift; sourceTree = "<group>"; };
@@ -456,6 +458,7 @@
54448A3124899A4000771C96 /* SearchBarManager.swift */,
549ECD9C24A7AD550097571C /* CustomAlert.swift */,
541FC47524A12D01009154D8 /* IBViews.swift */,
5404AEEC24A95F3F003B2F54 /* SlideInAnimation.swift */,
);
path = "Common Classes";
sourceTree = "<group>";
@@ -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;
};

View File

@@ -6,19 +6,7 @@ class CustomAlert<CustomView: UIView>: 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<CustomView: UIView>: 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<CustomView: UIView>: 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<CustomView: UIView>: 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<CustomView: UIView>: UIViewController {
class DatePickerAlert : CustomAlert<UIDatePicker> {
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<UIDatePicker> {
class DurationPickerAlert: CustomAlert<UIPickerView>, 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") }

View File

@@ -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, *) {

View File

@@ -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))
}
}
}

View File

@@ -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 {