Alerts with custom views

This commit is contained in:
relikd
2020-06-28 00:59:48 +02:00
parent e315e71d07
commit 43de81929f
5 changed files with 260 additions and 111 deletions

View File

@@ -0,0 +1,241 @@
import UIKit
class CustomAlert<CustomView: UIView>: UIViewController {
private let alertTitle: String?
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
}()
/// Default: `[Cancel, Save]`
let buttonsBar: UIStackView = {
let cancel = QuickUI.button("Cancel", target: self, action: #selector(didTapCancel))
let save = QuickUI.button("Save", target: self, action: #selector(didTapSave))
save.titleLabel?.font = save.titleLabel?.font.bold()
let bar = UIStackView(arrangedSubviews: [cancel, save])
bar.axis = .horizontal
bar.distribution = .equalSpacing
return bar
}()
// MARK: - Init
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
init(title: String? = nil, detail: String? = nil, view custom: CustomView) {
alertTitle = title
alertDetail = detail
customView = custom
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .custom
if #available(iOS 13.0, *) {
isModalInPresentation = true
}
}
internal override func loadView() {
view = UIView()
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.isHidden = true // otherwise control will flash on present
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)
// chain views vertically
x.topAnchor =&= (prevView?.bottomAnchor ?? control.topAnchor) + top
prevView = x
h += x.frame.height + top
}
if let t = alertTitle {
let lbl = QuickUI.label(t, align: .center, style: .headline)
lbl.numberOfLines = 0
appendView(lbl, top: 16, lr: 16)
}
if let d = alertDetail {
let lbl = QuickUI.label(d, align: .center, style: .subheadline)
lbl.numberOfLines = 0
appendView(lbl, top: 16, lr: 16)
}
appendView(customView, top: (prevView == nil) ? 0 : 16, lr: 0)
appendView(buttonsBar, top: 0, lr: 25)
buttonsBar.bottomAnchor =&= control.bottomAnchor - 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)
view.addSubview(control)
control.anchor([.leading, .trailing, .bottom], to: view!)
}
// MARK: - User Interaction
override var keyCommands: [UIKeyCommand]? {
[UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(didTapCancel))]
}
@objc private func didTapCancel() {
callback = nil
dismiss(animated: true)
}
@objc private func didTapSave() {
dismiss(animated: true) {
self.callback(self.customView)
self.callback = nil
}
}
// MARK: - Present & Dismiss
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)
}
}
}
// ###################################
// #
// # MARK: - Date Picker Alert
// #
// ###################################
class DatePickerAlert : CustomAlert<UIDatePicker> {
let datePicker: UIDatePicker = {
let x = UIDatePicker()
x.frame.size.height = x.sizeThatFits(.zero).height
return x
}()
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
init(title: String? = nil, detail: String? = nil, initial date: Date? = nil) {
if let date = date {
datePicker.setDate(date, animated: false)
}
super.init(title: title, detail: detail, view: datePicker)
let now = QuickUI.button("Now", target: self, action: #selector(didTapNow))
now.titleLabel?.font = now.titleLabel?.font.bold()
now.setTitleColor(.sysLabel, for: .normal)
buttonsBar.insertArrangedSubview(now, at: 1)
}
@objc private func didTapNow() {
datePicker.date = Date()
}
func present(in viewController: UIViewController, onSuccess: @escaping (Date) -> Void) {
super.present(in: viewController) {
onSuccess($0.date)
}
}
}
// #######################################
// #
// # MARK: - Duration Picker Alert
// #
// #######################################
class DurationPickerAlert: CustomAlert<UIPickerView>, UIPickerViewDataSource, UIPickerViewDelegate {
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") }
/// - Parameter options: [[List of labels] per component]
/// - Parameter widths: If `nil` set all components to equal width
init(title: String? = nil, detail: String? = nil, options: [[String]], widths: [CGFloat]? = nil) {
assert(widths == nil || widths!.count == options.count, "widths.count != options.count")
dataSource = options
compWidths = widths ?? options.map { _ in 1 / CGFloat(options.count) }
super.init(title: title, detail: detail, view: pickerView)
pickerView.dataSource = self
pickerView.delegate = self
}
func numberOfComponents(in _: UIPickerView) -> Int {
dataSource.count
}
func pickerView(_: UIPickerView, numberOfRowsInComponent c: Int) -> Int {
dataSource[c].count
}
func pickerView(_: UIPickerView, titleForRow r: Int, forComponent c: Int) -> String? {
dataSource[c][r]
}
func pickerView(_ pickerView: UIPickerView, widthForComponent c: Int) -> CGFloat {
compWidths[c] * pickerView.frame.width
}
func present(in viewController: UIViewController, onSuccess: @escaping ([Int]) -> Void) {
super.present(in: viewController) {
onSuccess($0.selection)
}
}
}
extension UIPickerView {
var selection: [Int] {
get { (0..<numberOfComponents).map { selectedRow(inComponent: $0) } }
set { setSelection(newValue) }
}
/// - Warning: Does not check for boundaries!
func setSelection(_ selection: [Int], animated: Bool = false) {
assert(selection.count == numberOfComponents, "selection.count != components.count")
for (c, i) in selection.enumerated() {
selectRow(i, inComponent: c, animated: animated)
}
}
}

View File

@@ -1,102 +0,0 @@
import UIKit
class DatePickerAlert: UIViewController {
override var keyCommands: [UIKeyCommand]? {
[UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(didTapCancel))]
}
private var callback: (Date) -> Void
private let picker: UIDatePicker = {
let x = UIDatePicker()
let h = x.sizeThatFits(.zero).height
x.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: h)
return x
}()
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
@discardableResult required init(presentIn viewController: UIViewController, configure: ((UIDatePicker) -> Void)? = nil, onSuccess: @escaping (Date) -> Void) {
callback = onSuccess
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .custom
if #available(iOS 13.0, *) {
isModalInPresentation = true
}
presentIn(viewController, configure)
}
internal override func loadView() {
let cancel = QuickUI.button("Discard", target: self, action: #selector(didTapCancel))
let save = QuickUI.button("Save", target: self, action: #selector(didTapSave))
let now = QuickUI.button("Now", target: self, action: #selector(didTapNow))
save.titleLabel?.font = save.titleLabel?.font.bold()
now.titleLabel?.font = now.titleLabel?.font.bold()
now.setTitleColor(.sysLabel, for: .normal)
//cancel.setTitleColor(.systemRed, for: .normal)
let buttons = UIStackView(arrangedSubviews: [cancel, now, save])
buttons.axis = .horizontal
buttons.distribution = .equalSpacing
let bg = UIView(frame: picker.frame)
bg.frame.size.height += buttons.frame.height + 15
bg.frame.origin.y = UIScreen.main.bounds.height - bg.frame.height - 15
bg.backgroundColor = .sysBackground
bg.addSubview(picker)
bg.addSubview(buttons)
let clearBg = UIView()
clearBg.autoresizingMask = [.flexibleWidth, .flexibleHeight]
clearBg.addSubview(bg)
picker.anchor([.leading, .trailing, .top], to: bg)
picker.bottomAnchor =&= buttons.topAnchor
buttons.anchor([.leading, .trailing], to: bg, margin: 25)
buttons.bottomAnchor =&= bg.bottomAnchor - 15
bg.anchor([.leading, .trailing, .bottom], to: clearBg)
view = clearBg
view.isHidden = true // otherwise picker will flash on present
}
@objc private func didTapNow() {
picker.date = Date()
}
@objc private func didTapSave() {
dismiss(animated: true) {
self.callback(self.picker.date)
}
}
@objc private func didTapCancel() {
dismiss(animated: true)
}
private func presentIn(_ viewController: UIViewController, _ configure: ((UIDatePicker) -> Void)? = nil) {
viewController.present(self, animated: false) {
let control = self.view.subviews.first!
let prev = control.frame.origin.y
control.frame.origin.y += control.frame.height
self.view.isHidden = false
configure?(self.picker)
UIView.animate(withDuration: 0.3) {
self.view.backgroundColor = UIColor.black.withAlphaComponent(0.5)
control.frame.origin.y = prev
}
}
}
override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
UIView.animate(withDuration: 0.3, animations: {
let control = self.view.subviews.first!
self.view.backgroundColor = .clear
control.frame.origin.y += control.frame.height
}) { _ in
super.dismiss(animated: false, completion: completion)
}
}
}

View File

@@ -2,6 +2,17 @@ import UIKit
struct QuickUI {
static func label(_ str: String, frame: CGRect = CGRect.zero, align: NSTextAlignment = .natural, style: UIFont.TextStyle = .body) -> UILabel {
let x = UILabel(frame: frame)
x.text = str
x.textAlignment = align
x.font = .preferredFont(forTextStyle: style)
if #available(iOS 10.0, *) {
x.adjustsFontForContentSizeCategory = true
}
return x
}
static func button(_ title: String, target: Any? = nil, action: Selector? = nil) -> UIButton {
let x = UIButton(type: .roundedRect)
x.setTitle(title, for: .normal)

View File

@@ -63,10 +63,9 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate {
@IBAction private func didTapRangeButton(_ sender: UIButton) {
let flag = (sender == buttonRangeStart)
DatePickerAlert(presentIn: self, configure: {
$0.setDate(Date(flag ? self.tsRangeA : self.tsRangeB), animated: false)
}, onSuccess: {
var ts = $0.timestamp
let oldDate = flag ? Date(self.tsRangeA) : Date(self.tsRangeB)
DatePickerAlert(initial: oldDate).present(in: self) { (selected: Date) in
var ts = selected.timestamp
ts -= ts % 60 // remove seconds
// if one of these is greater than the other, adjust the latter too.
if flag || self.tsRangeA > ts {
@@ -77,7 +76,7 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate {
self.tsRangeB = ts + 59 // upper end of minute
self.buttonRangeEnd.setTitle(DateFormat.minutes(ts + 59), for: .normal)
}
})
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {