394 lines
15 KiB
Swift
394 lines
15 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!
|
||
|
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
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|