// // 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 = .systemBackground 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 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 } } 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) } override func touchesBegan(_ touches: Set, 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, with event: UIEvent?) { super.touchesMoved(touches, with: event) } override func touchesEnded(_ touches: Set, 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) 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 } } }