551 lines
21 KiB
Swift
551 lines
21 KiB
Swift
//
|
|
// CustomAlertController.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 9/17/22.
|
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
class CustomAlertController: UIViewController {
|
|
private let config: Configuration
|
|
|
|
fileprivate var blurView: UIVisualEffectView!
|
|
fileprivate var dimmingView: UIView!
|
|
fileprivate var buttonsStack: UIStackView!
|
|
fileprivate var actionsView: CustomAlertActionsView!
|
|
|
|
init(config: Configuration) {
|
|
self.config = config
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
transitioningDelegate = self
|
|
modalPresentationStyle = .custom
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
view.backgroundColor = .clear
|
|
|
|
dimmingView = UIView()
|
|
dimmingView.backgroundColor = .black.withAlphaComponent(0.2)
|
|
dimmingView.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(dimmingView)
|
|
NSLayoutConstraint.activate([
|
|
dimmingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
dimmingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
dimmingView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
dimmingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
])
|
|
|
|
blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
|
|
|
|
blurView.backgroundColor = .appBackground
|
|
blurView.layer.cornerRadius = 15
|
|
blurView.layer.cornerCurve = .continuous
|
|
blurView.layer.masksToBounds = true
|
|
blurView.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(blurView)
|
|
NSLayoutConstraint.activate([
|
|
blurView.widthAnchor.constraint(equalToConstant: 270),
|
|
blurView.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
|
|
blurView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
blurView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
])
|
|
|
|
let stack = UIStackView()
|
|
stack.axis = .vertical
|
|
stack.spacing = 0
|
|
stack.alignment = .fill
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
blurView.contentView.addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor),
|
|
blurView.contentView.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
|
|
stack.topAnchor.constraint(equalTo: blurView.contentView.topAnchor, constant: 16),
|
|
stack.bottomAnchor.constraint(equalTo: blurView.contentView.bottomAnchor),
|
|
])
|
|
|
|
let titleLabel = UILabel()
|
|
titleLabel.text = config.title
|
|
titleLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
|
|
titleLabel.adjustsFontForContentSizeCategory = true
|
|
titleLabel.numberOfLines = 0
|
|
titleLabel.textAlignment = .center
|
|
stack.addArrangedSubview(titleLabel)
|
|
|
|
stack.addSpacer(length: 8)
|
|
|
|
stack.addArrangedSubview(config.content)
|
|
|
|
stack.addSpacer(length: 16)
|
|
|
|
let separator = UIView()
|
|
separator.tag = ViewTags.customAlertSeparator
|
|
separator.backgroundColor = .separator
|
|
stack.addArrangedSubview(separator)
|
|
NSLayoutConstraint.activate([
|
|
separator.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
|
separator.heightAnchor.constraint(equalToConstant: 0.5),
|
|
])
|
|
|
|
actionsView = CustomAlertActionsView(config: config, dismiss: { [unowned self] in
|
|
self.dismiss(animated: true)
|
|
})
|
|
stack.addArrangedSubview(actionsView)
|
|
NSLayoutConstraint.activate([
|
|
actionsView.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
|
])
|
|
}
|
|
|
|
struct Configuration {
|
|
var title: String
|
|
var content: UIView
|
|
var actions: [Action]
|
|
}
|
|
|
|
struct Action {
|
|
var title: String?
|
|
var image: UIImage?
|
|
var style: Style
|
|
var handler: (() -> Void)?
|
|
var isSecondaryMenu: Bool = false
|
|
|
|
init(title: String?, image: UIImage? = nil, style: Style, handler: (() -> Void)?) {
|
|
self.title = title
|
|
self.image = image
|
|
self.style = style
|
|
self.handler = handler
|
|
}
|
|
|
|
enum Style {
|
|
case `default`, cancel, destructive, menu([MenuAction])
|
|
}
|
|
}
|
|
|
|
struct MenuAction {
|
|
var title: String
|
|
var subtitle: String?
|
|
var image: UIImage?
|
|
var handler: () -> Void
|
|
}
|
|
}
|
|
|
|
class CustomAlertActionsView: UIControl {
|
|
private let dismiss: () -> Void
|
|
|
|
private let stack = UIStackView()
|
|
private var actionButtons: [CustomAlertActionButton] = []
|
|
private var labelWrapperWidthConstraints: [NSLayoutConstraint] = []
|
|
// the actions from the config but reordered to match labelWrappers order
|
|
private var reorderedActions: [CustomAlertController.Action] = []
|
|
private var separators: [UIView] = []
|
|
private var separatorSizeConstraints: [NSLayoutConstraint] = []
|
|
|
|
private let generator = UISelectionFeedbackGenerator()
|
|
private var currentSelectedActionIndex: Int?
|
|
|
|
init(config: CustomAlertController.Configuration, dismiss: @escaping () -> Void) {
|
|
self.dismiss = dismiss
|
|
|
|
super.init(frame: .zero)
|
|
|
|
stack.alignment = .fill
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
stack.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
stack.topAnchor.constraint(equalTo: topAnchor),
|
|
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
])
|
|
|
|
for action in config.actions {
|
|
let button = CustomAlertActionButton(action: action, dismiss: dismiss)
|
|
|
|
button.isAccessibilityElement = true
|
|
button.accessibilityTraits = .button
|
|
button.accessibilityRespondsToUserInteraction = true
|
|
button.accessibilityLabel = action.title
|
|
|
|
if action.isSecondaryMenu {
|
|
button.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
|
|
}
|
|
|
|
if case .cancel = action.style {
|
|
actionButtons.insert(button, at: 0)
|
|
reorderedActions.insert(action, at: 0)
|
|
} else {
|
|
actionButtons.append(button)
|
|
reorderedActions.append(action)
|
|
}
|
|
}
|
|
|
|
var first = true
|
|
for (action, button) in zip(reorderedActions, actionButtons) {
|
|
if first {
|
|
first = false
|
|
} else if !action.isSecondaryMenu {
|
|
let separator = UIView()
|
|
separator.tag = ViewTags.customAlertSeparator
|
|
separator.backgroundColor = .separator
|
|
stack.addArrangedSubview(separator)
|
|
separators.append(separator)
|
|
}
|
|
if action.isSecondaryMenu {
|
|
// prev button
|
|
let prev = stack.arrangedSubviews.last!
|
|
stack.removeArrangedSubview(prev)
|
|
let separator = UIView()
|
|
separator.tag = ViewTags.customAlertSeparator
|
|
separator.backgroundColor = .separator
|
|
separator.widthAnchor.constraint(equalToConstant: 0.5).isActive = true
|
|
let hStack = UIStackView(arrangedSubviews: [prev, separator, button])
|
|
hStack.axis = .horizontal
|
|
stack.addArrangedSubview(hStack)
|
|
} else {
|
|
stack.addArrangedSubview(button)
|
|
}
|
|
}
|
|
|
|
addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(panRecognized)))
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
updateAxis()
|
|
super.layoutSubviews()
|
|
}
|
|
|
|
private var menuActionsCount: Int {
|
|
var count = 0
|
|
for action in reorderedActions {
|
|
if case .menu(_) = action.style {
|
|
count += 1
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
private var needsVertical: Bool {
|
|
if reorderedActions.count - menuActionsCount > 2 {
|
|
return false
|
|
}
|
|
var requiredWidth: CGFloat = 0
|
|
for (index, action) in actionButtons.enumerated() {
|
|
if index > 0 {
|
|
requiredWidth += 0.5
|
|
}
|
|
requiredWidth += action.titleView.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize).width
|
|
requiredWidth += 8
|
|
}
|
|
return requiredWidth > bounds.width
|
|
}
|
|
|
|
private func updateAxis() {
|
|
if needsVertical {
|
|
stack.axis = .vertical
|
|
NSLayoutConstraint.deactivate(labelWrapperWidthConstraints)
|
|
labelWrapperWidthConstraints = []
|
|
NSLayoutConstraint.deactivate(separatorSizeConstraints)
|
|
separatorSizeConstraints = separators.map {
|
|
$0.heightAnchor.constraint(equalToConstant: 0.5)
|
|
}
|
|
NSLayoutConstraint.activate(separatorSizeConstraints)
|
|
} else {
|
|
stack.axis = .horizontal
|
|
labelWrapperWidthConstraints = []
|
|
var nonMenuAction: CustomAlertActionButton?
|
|
for (index, action) in reorderedActions.enumerated() {
|
|
if case .menu(_) = action.style {
|
|
} else {
|
|
if let nonMenuAction {
|
|
labelWrapperWidthConstraints.append(
|
|
actionButtons[index].widthAnchor.constraint(equalTo: nonMenuAction.widthAnchor)
|
|
)
|
|
} else {
|
|
nonMenuAction = actionButtons[index]
|
|
}
|
|
}
|
|
}
|
|
NSLayoutConstraint.activate(labelWrapperWidthConstraints)
|
|
NSLayoutConstraint.deactivate(separatorSizeConstraints)
|
|
separatorSizeConstraints = separators.map {
|
|
$0.widthAnchor.constraint(equalToConstant: 0.5)
|
|
}
|
|
NSLayoutConstraint.activate(separatorSizeConstraints)
|
|
}
|
|
}
|
|
|
|
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
|
|
let selectedButton = actionButtons.enumerated().first {
|
|
$0.1.point(inside: recognizer.location(in: $0.1), with: nil)
|
|
}
|
|
|
|
switch recognizer.state {
|
|
case .began:
|
|
currentSelectedActionIndex = selectedButton?.offset
|
|
selectedButton?.element.backgroundColor = .secondarySystemFill
|
|
|
|
generator.prepare()
|
|
|
|
case .changed:
|
|
if selectedButton == nil && hitTest(recognizer.location(in: self), with: nil)?.tag == ViewTags.customAlertSeparator {
|
|
break
|
|
}
|
|
|
|
if selectedButton?.offset != currentSelectedActionIndex {
|
|
if let currentSelectedActionIndex {
|
|
actionButtons[currentSelectedActionIndex].backgroundColor = nil
|
|
}
|
|
generator.selectionChanged()
|
|
}
|
|
|
|
currentSelectedActionIndex = selectedButton?.offset
|
|
selectedButton?.element.backgroundColor = .secondarySystemFill
|
|
|
|
generator.prepare()
|
|
|
|
case .ended:
|
|
if let currentSelectedActionIndex {
|
|
let button = actionButtons[currentSelectedActionIndex]
|
|
button.backgroundColor = nil
|
|
let action = reorderedActions[currentSelectedActionIndex]
|
|
if action.handler == nil,
|
|
case .menu(_) = action.style,
|
|
let interaction = button.contextMenuInteraction {
|
|
let selector = NSSelectorFromString(["Location:", "At", "Menu", "present", "_"].reversed().joined())
|
|
if interaction.responds(to: selector) {
|
|
interaction.perform(selector, with: recognizer.location(in: button))
|
|
}
|
|
} else {
|
|
action.handler?()
|
|
self.dismiss()
|
|
}
|
|
}
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
class CustomAlertActionButton: UIControl {
|
|
private let action: CustomAlertController.Action
|
|
private let dismiss: () -> Void
|
|
|
|
var titleView = UIStackView()
|
|
|
|
init(action: CustomAlertController.Action, dismiss: @escaping () -> Void) {
|
|
precondition(action.title != nil || action.image != nil, "action must have image and/or title")
|
|
|
|
self.action = action
|
|
self.dismiss = dismiss
|
|
|
|
super.init(frame: .zero)
|
|
|
|
titleView = UIStackView()
|
|
titleView.axis = .horizontal
|
|
titleView.spacing = 4
|
|
titleView.alignment = .center
|
|
|
|
if let title = action.title {
|
|
let label = UILabel()
|
|
label.text = title
|
|
label.textColor = .tintColor
|
|
label.adjustsFontForContentSizeCategory = true
|
|
if case .cancel = action.style {
|
|
label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0)
|
|
} else {
|
|
label.font = .preferredFont(forTextStyle: .body)
|
|
}
|
|
if case .destructive = action.style {
|
|
label.textColor = .systemRed
|
|
}
|
|
titleView.addArrangedSubview(label)
|
|
}
|
|
|
|
if let image = action.image {
|
|
let imageView = UIImageView(image: image)
|
|
titleView.addArrangedSubview(imageView)
|
|
}
|
|
|
|
titleView.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(titleView)
|
|
NSLayoutConstraint.activate([
|
|
titleView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 4),
|
|
titleView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -4),
|
|
titleView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
titleView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
heightAnchor.constraint(equalToConstant: 44),
|
|
])
|
|
|
|
if case .menu(_) = action.style {
|
|
self.isContextMenuInteractionEnabled = true
|
|
self.showsMenuAsPrimaryAction = action.handler == nil
|
|
}
|
|
|
|
addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized)))
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
|
guard case .menu(let menuActions) = action.style else {
|
|
return nil
|
|
}
|
|
return UIContextMenuConfiguration(actionProvider: { _ in
|
|
return UIMenu(children: menuActions.map { action in
|
|
UIAction(title: action.title, subtitle: action.subtitle, image: action.image) { [unowned self] _ in
|
|
action.handler()
|
|
self.dismiss()
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
|
|
super.contextMenuInteraction(interaction, willDisplayMenuFor: configuration, animator: animator)
|
|
if let animator {
|
|
animator.addAnimations {
|
|
self.backgroundColor = nil
|
|
}
|
|
} else {
|
|
backgroundColor = nil
|
|
}
|
|
}
|
|
|
|
override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
|
|
super.contextMenuInteraction(interaction, willEndFor: configuration, animator: animator)
|
|
}
|
|
|
|
@objc func hoverRecognized(_ recognizer: UIHoverGestureRecognizer) {
|
|
switch recognizer.state {
|
|
case .began, .changed:
|
|
backgroundColor = .secondarySystemFill
|
|
case .ended:
|
|
backgroundColor = nil
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
super.touchesBegan(touches, with: event)
|
|
|
|
if point(inside: touches.first!.location(in: self), with: event) {
|
|
backgroundColor = .secondarySystemFill
|
|
}
|
|
}
|
|
|
|
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
super.touchesMoved(touches, with: event)
|
|
}
|
|
|
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
super.touchesEnded(touches, with: event)
|
|
backgroundColor = nil
|
|
action.handler?()
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
extension CustomAlertController {
|
|
override func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
|
return CustomAlertPresentationAnimation()
|
|
}
|
|
|
|
override func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
|
return CustomAlertDismissAnimation()
|
|
}
|
|
}
|
|
|
|
class CustomAlertPresentationAnimation: NSObject, UIViewControllerAnimatedTransitioning {
|
|
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
|
return 0.2
|
|
}
|
|
|
|
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
|
guard let presenter = transitionContext.viewController(forKey: .from),
|
|
let alert = transitionContext.viewController(forKey: .to) as? CustomAlertController else {
|
|
transitionContext.completeTransition(false)
|
|
return
|
|
}
|
|
|
|
let container = transitionContext.containerView
|
|
container.addSubview(alert.view)
|
|
alert.view.frame = container.bounds
|
|
|
|
guard transitionContext.isAnimated else {
|
|
presenter.view.tintAdjustmentMode = .dimmed
|
|
transitionContext.completeTransition(true)
|
|
return
|
|
}
|
|
|
|
alert.dimmingView.layer.opacity = 0
|
|
alert.blurView.layer.opacity = 0
|
|
alert.blurView.transform = CGAffineTransform(scaleX: 1.1, y: 1.1)
|
|
|
|
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: .curveEaseOut) {
|
|
presenter.view.tintAdjustmentMode = .dimmed
|
|
alert.dimmingView.layer.opacity = 1
|
|
alert.blurView.layer.opacity = 1
|
|
alert.blurView.transform = .identity
|
|
} completion: { _ in
|
|
transitionContext.completeTransition(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
class CustomAlertDismissAnimation: NSObject, UIViewControllerAnimatedTransitioning {
|
|
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
|
return 0.2
|
|
}
|
|
|
|
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
|
guard let presenter = transitionContext.viewController(forKey: .to),
|
|
let alert = transitionContext.viewController(forKey: .from) as? CustomAlertController else {
|
|
transitionContext.completeTransition(false)
|
|
return
|
|
}
|
|
|
|
guard transitionContext.isAnimated else {
|
|
presenter.view.tintAdjustmentMode = .dimmed
|
|
transitionContext.completeTransition(true)
|
|
return
|
|
}
|
|
|
|
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0) {
|
|
presenter.view.tintAdjustmentMode = .automatic
|
|
alert.view.layer.opacity = 0
|
|
} completion: { _ in
|
|
transitionContext.completeTransition(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate extension UIStackView {
|
|
func addSpacer(length: CGFloat) {
|
|
let spacer = UIView()
|
|
addArrangedSubview(spacer)
|
|
if axis == .vertical {
|
|
spacer.heightAnchor.constraint(equalToConstant: length).isActive = true
|
|
} else {
|
|
spacer.widthAnchor.constraint(equalToConstant: length).isActive = true
|
|
}
|
|
}
|
|
}
|
|
|