// // 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! private var separatorHeightConstraint: NSLayoutConstraint! 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.numberOfLines = 0 titleLabel.textAlignment = .center stack.addArrangedSubview(titleLabel) stack.addSpacer(length: 8) stack.addArrangedSubview(config.content) stack.addSpacer(length: 16) let separator = UIView() separator.backgroundColor = .separator stack.addArrangedSubview(separator) separatorHeightConstraint = separator.heightAnchor.constraint(equalToConstant: 0.5) NSLayoutConstraint.activate([ separator.widthAnchor.constraint(equalTo: stack.widthAnchor), separatorHeightConstraint, ]) actionsView = CustomAlertActionsView(config: config, dismiss: { [unowned self] in self.dismiss(animated: true) }) stack.addArrangedSubview(actionsView) NSLayoutConstraint.activate([ actionsView.widthAnchor.constraint(equalTo: stack.widthAnchor), ]) } override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() separatorHeightConstraint.constant = 1 / view.window!.screen.scale } struct Configuration { var title: String var content: UIView var actions: [Action] } struct Action { let title: String let style: UIAlertAction.Style let handler: (() -> Void)? } } class CustomAlertActionsView: UIControl { private let dismiss: () -> Void private let stack = UIStackView() private var labels: [UIView] = [] private var labelWrappers: [UIView] = [] 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 labelWrapper = UIView() labelWrapper.isAccessibilityElement = true labelWrapper.accessibilityTraits = .button labelWrapper.accessibilityRespondsToUserInteraction = true labelWrapper.accessibilityLabel = action.title let label = UILabel() labels.append(label) label.text = action.title label.textColor = .tintColor switch action.style { case .cancel: label.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold)!, size: 0) case .destructive: label.textColor = .systemRed default: break } label.translatesAutoresizingMaskIntoConstraints = false labelWrapper.addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(greaterThanOrEqualTo: labelWrapper.leadingAnchor, constant: 4), label.trailingAnchor.constraint(lessThanOrEqualTo: labelWrapper.trailingAnchor, constant: -4), label.centerXAnchor.constraint(equalTo: labelWrapper.centerXAnchor), label.centerYAnchor.constraint(equalTo: labelWrapper.centerYAnchor), labelWrapper.heightAnchor.constraint(equalToConstant: 44), ]) if action.style == .cancel { labelWrappers.insert(labelWrapper, at: 0) reorderedActions.insert(action, at: 0) } else { labelWrappers.append(labelWrapper) reorderedActions.append(action) } } var first = true for wrapper in labelWrappers { if first { first = false } else { let separator = UIView() separator.backgroundColor = .separator stack.addArrangedSubview(separator) separators.append(separator) } stack.addArrangedSubview(wrapper) } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { updateAxis() super.layoutSubviews() } private func updateAxis() { if reorderedActions.count > 2 || labels.map({ $0.intrinsicContentSize.width }).contains(where: { $0 > (bounds.width - 16) / 2 }) { 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 = labelWrappers.map { $0.widthAnchor.constraint(equalToConstant: (bounds.width - 0.5) / 2) } NSLayoutConstraint.activate(labelWrapperWidthConstraints) NSLayoutConstraint.deactivate(separatorSizeConstraints) separatorSizeConstraints = separators.map { $0.widthAnchor.constraint(equalToConstant: 0.5) } NSLayoutConstraint.activate(separatorSizeConstraints) } } // MARK: - UIControl override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { // we want this view to handle all touches inside it return self } override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { for (index, labelWrapper) in labelWrappers.enumerated() { if labelWrapper.point(inside: touch.location(in: labelWrapper), with: event) { currentSelectedActionIndex = index labelWrapper.backgroundColor = .secondarySystemFill generator.prepare() return true } } return false } override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { for (index, labelWrapper) in labelWrappers.enumerated() { if labelWrapper.point(inside: touch.location(in: labelWrapper), with: event) { if index != currentSelectedActionIndex { if let currentSelectedActionIndex { labelWrappers[currentSelectedActionIndex].backgroundColor = nil } generator.selectionChanged() } currentSelectedActionIndex = index labelWrapper.backgroundColor = .secondarySystemFill generator.prepare() return true } } // didn't hit any button if let currentSelectedActionIndex { labelWrappers[currentSelectedActionIndex].backgroundColor = nil self.currentSelectedActionIndex = nil } return true } override func endTracking(_ touch: UITouch?, with event: UIEvent?) { super.endTracking(touch, with: event) if let currentSelectedActionIndex { labelWrappers[currentSelectedActionIndex].backgroundColor = nil reorderedActions[currentSelectedActionIndex].handler?() dismiss() } } override func cancelTracking(with event: UIEvent?) { super.cancelTracking(with: event) if let currentSelectedActionIndex { labelWrappers[currentSelectedActionIndex].backgroundColor = nil } } } 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 } } }