Tusker/Tusker/Screens/Utilities/CustomAlertController.swift

536 lines
20 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 = .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<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)
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
}
}
}