// // WidescreenNavigationPrefsView.swift // Tusker // // Created by Shadowfacts on 9/2/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import SwiftUI import Combine struct WidescreenNavigationPrefsView: View { @ObservedObject private var preferences = Preferences.shared @State private var startAnimation = PassthroughSubject() var body: some View { HStack { Spacer() OptionView( value: .stack, selection: $preferences.widescreenNavigationMode, startAnimation: startAnimation ) { Text("Stack") } Spacer(minLength: 32) OptionView( value: .splitScreen, selection: $preferences.widescreenNavigationMode, startAnimation: startAnimation ) { Text("Split Screen") } if preferences.hasFeatureFlag(.iPadMultiColumn) { Spacer(minLength: 32) OptionView( value: .multiColumn, selection: $preferences.widescreenNavigationMode, startAnimation: startAnimation ) { Text("Multi-Column") } } Spacer() } .frame(height: 100) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { startAnimation.send() } } } } private struct OptionView: View { let value: Preferences.WidescreenNavigationMode @Binding var selection: Preferences.WidescreenNavigationMode let startAnimation: PassthroughSubject @ViewBuilder let label: Text @Environment(\.colorScheme) private var colorScheme private var selected: Bool { selection == value } var body: some View { Button(action: self.selectValue) { VStack { preview label .foregroundStyle(selected ? Color.white : .primary) .background(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.clear), in: WideCapsule()) } } .buttonStyle(.plain) } private var preview: some View { NavigationModeRepresentable(startAnimation: startAnimation) .clipShape(RoundedRectangle(cornerRadius: 12.5, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 12.5, style: .continuous) .stroke(.gray, lineWidth: 3) } .aspectRatio(4/3, contentMode: .fit) } private func selectValue() { selection = value } } private struct WideCapsule: Shape { func path(in rect: CGRect) -> Path { Capsule().path(in: rect.insetBy(dx: -6, dy: -2)) } } @MainActor private protocol NavigationModePreview: UIView { init(startAnimation: PassthroughSubject) } private struct NavigationModeRepresentable: UIViewRepresentable { let startAnimation: PassthroughSubject func makeUIView(context: Context) -> UIViewType { UIViewType(startAnimation: startAnimation) } func updateUIView(_ uiView: UIViewType, context: Context) { } } @MainActor private let timingParams = UISpringTimingParameters(mass: 1, stiffness: 70, damping: 16, initialVelocity: .zero) private final class StackNavigationPreview: UIView, NavigationModePreview { private let cellStack = UIStackView(arrangedSubviews: [CellView(), CellView(), CellView()]) private let destinationView = UIView() private var cancellable: AnyCancellable? init(startAnimation: PassthroughSubject) { super.init(frame: .zero) backgroundColor = .appBackground layer.masksToBounds = true cellStack.axis = .vertical cellStack.spacing = 0 cellStack.distribution = .fillEqually cellStack.translatesAutoresizingMaskIntoConstraints = false addSubview(cellStack) cellStack.arrangedSubviews[1].backgroundColor = .clear destinationView.translatesAutoresizingMaskIntoConstraints = false destinationView.backgroundColor = .tintColor destinationView.isHidden = true addSubview(destinationView) NSLayoutConstraint.activate([ cellStack.leadingAnchor.constraint(equalTo: leadingAnchor), cellStack.trailingAnchor.constraint(equalTo: trailingAnchor), cellStack.topAnchor.constraint(equalTo: topAnchor, constant: 4), cellStack.bottomAnchor.constraint(equalTo: bottomAnchor), destinationView.leadingAnchor.constraint(equalTo: leadingAnchor), destinationView.trailingAnchor.constraint(equalTo: trailingAnchor), destinationView.topAnchor.constraint(equalTo: topAnchor), destinationView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) cancellable = startAnimation.sink { [unowned self] _ in self.startAnimation() } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func startAnimation() { destinationView.transform = CGAffineTransform(translationX: destinationView.bounds.width, y: 0) destinationView.isHidden = false let animator = UIViewPropertyAnimator(duration: 1, timingParameters: timingParams) animator.addAnimations { self.cellStack.arrangedSubviews[1].backgroundColor = .tertiaryLabel self.cellStack.transform = CGAffineTransform(translationX: -0.3 * self.cellStack.bounds.width, y: 0) self.destinationView.transform = .identity } animator.addCompletion { [weak self] _ in self?.reverseAnimation() } animator.startAnimation(afterDelay: 0.6) } private func reverseAnimation() { let animator = UIViewPropertyAnimator(duration: 1, timingParameters: timingParams) animator.addAnimations { self.cellStack.arrangedSubviews[1].backgroundColor = .clear self.cellStack.transform = .identity self.destinationView.transform = CGAffineTransform(translationX: self.destinationView.bounds.width, y: 0) } animator.addCompletion { [weak self] _ in self?.startAnimation() } animator.startAnimation(afterDelay: 0.5) } } private final class SplitNavigationPreview: UIView, NavigationModePreview { private let cellStack = UIStackView(arrangedSubviews: [CellView(), CellView(), CellView()]) private let destinationView = UIView() private var cellStackTrailingConstraint: NSLayoutConstraint! private var cancellable: AnyCancellable? init(startAnimation: PassthroughSubject) { super.init(frame: .zero) backgroundColor = .appBackground layer.masksToBounds = true cellStack.axis = .vertical cellStack.spacing = 0 cellStack.distribution = .fillEqually cellStack.translatesAutoresizingMaskIntoConstraints = false addSubview(cellStack) cellStack.arrangedSubviews[1].backgroundColor = .clear destinationView.translatesAutoresizingMaskIntoConstraints = false destinationView.backgroundColor = .tintColor destinationView.isHidden = true addSubview(destinationView) cellStackTrailingConstraint = cellStack.trailingAnchor.constraint(equalTo: trailingAnchor) NSLayoutConstraint.activate([ cellStack.leadingAnchor.constraint(equalTo: leadingAnchor), cellStackTrailingConstraint, cellStack.topAnchor.constraint(equalTo: topAnchor, constant: 4), cellStack.bottomAnchor.constraint(equalTo: bottomAnchor), destinationView.leadingAnchor.constraint(equalTo: centerXAnchor), destinationView.trailingAnchor.constraint(equalTo: trailingAnchor), destinationView.topAnchor.constraint(equalTo: topAnchor), destinationView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) cancellable = startAnimation.sink { [unowned self] _ in self.startAnimation() } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func startAnimation() { destinationView.transform = CGAffineTransform(translationX: destinationView.bounds.width, y: 0) destinationView.isHidden = false cellStackTrailingConstraint.isActive = false cellStackTrailingConstraint = cellStack.trailingAnchor.constraint(equalTo: centerXAnchor) cellStackTrailingConstraint.isActive = true let animator = UIViewPropertyAnimator(duration: 1, timingParameters: timingParams) animator.addAnimations { self.layoutIfNeeded() self.destinationView.transform = .identity } animator.addCompletion { [weak self] _ in self?.reverseAnimation() } animator.startAnimation(afterDelay: 0.6) UIView.animate(withDuration: 0.1, delay: 0.5, options: .curveEaseIn) { self.cellStack.arrangedSubviews[1].backgroundColor = .tertiaryLabel } completion: { _ in UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { self.cellStack.arrangedSubviews[1].backgroundColor = .clear } } } private func reverseAnimation() { cellStackTrailingConstraint.isActive = false cellStackTrailingConstraint = cellStack.trailingAnchor.constraint(equalTo: trailingAnchor) cellStackTrailingConstraint.isActive = true let animator = UIViewPropertyAnimator(duration: 1, timingParameters: timingParams) animator.addAnimations { self.layoutIfNeeded() self.destinationView.transform = CGAffineTransform(translationX: self.destinationView.bounds.width, y: 0) } animator.addCompletion { [weak self] _ in self?.startAnimation() } animator.startAnimation(afterDelay: 0.5) } } private final class MultiColumnNavigationPreview: UIView, NavigationModePreview { private static let columnSpacing: CGFloat = 5 private let cellStack1 = UIStackView(arrangedSubviews: [CellView(), CellView(), CellView()]) private let cellStack2 = UIStackView(arrangedSubviews: [CellView(), CellView(), CellView()]) private let destinationView = UIView() private var cancellable: AnyCancellable? private var startedAnimation = false init(startAnimation: PassthroughSubject) { super.init(frame: .zero) backgroundColor = .appSecondaryBackground layer.masksToBounds = true cellStack1.axis = .vertical cellStack1.spacing = 0 cellStack1.distribution = .fillEqually cellStack1.backgroundColor = .appBackground cellStack1.layer.cornerRadius = 6 cellStack1.translatesAutoresizingMaskIntoConstraints = false addSubview(cellStack1) cellStack2.axis = .vertical cellStack2.spacing = 0 cellStack2.distribution = .fillEqually cellStack2.backgroundColor = .appBackground cellStack2.layer.cornerRadius = 6 cellStack2.translatesAutoresizingMaskIntoConstraints = false addSubview(cellStack2) cellStack2.arrangedSubviews[1].backgroundColor = .clear destinationView.translatesAutoresizingMaskIntoConstraints = false destinationView.backgroundColor = .tintColor destinationView.layer.cornerRadius = 6 destinationView.layer.opacity = 0 addSubview(destinationView) NSLayoutConstraint.activate([ cellStack1.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.columnSpacing), cellStack1.widthAnchor.constraint(equalToConstant: 50), cellStack1.topAnchor.constraint(equalTo: topAnchor, constant: Self.columnSpacing), cellStack1.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.columnSpacing), cellStack2.leadingAnchor.constraint(equalTo: cellStack1.trailingAnchor, constant: Self.columnSpacing), cellStack2.widthAnchor.constraint(equalToConstant: 50), cellStack2.topAnchor.constraint(equalTo: topAnchor, constant: Self.columnSpacing), cellStack2.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.columnSpacing), destinationView.leadingAnchor.constraint(equalTo: cellStack2.trailingAnchor, constant: Self.columnSpacing), destinationView.widthAnchor.constraint(equalToConstant: 50), destinationView.topAnchor.constraint(equalTo: topAnchor, constant: Self.columnSpacing), destinationView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.columnSpacing), ]) cancellable = startAnimation.sink { [unowned self] _ in self.startedAnimation = true self.startAnimation() } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() if !startedAnimation { setUnscrolledTransform() } } private func startAnimation() { let totalWidth = 50 * 3 + Self.columnSpacing * 4 let offset = bounds.width - totalWidth let transform = CGAffineTransform(translationX: offset, y: 0) let animator = UIViewPropertyAnimator(duration: 1, timingParameters: timingParams) animator.addAnimations { self.cellStack1.transform = transform self.cellStack2.transform = transform self.destinationView.transform = transform self.destinationView.layer.opacity = 1 } animator.addCompletion { [weak self] _ in self?.reverseAnimation() } animator.startAnimation(afterDelay: 0.6) UIView.animate(withDuration: 0.1, delay: 0.5, options: .curveEaseIn) { self.cellStack2.arrangedSubviews[1].backgroundColor = .tertiaryLabel } completion: { _ in UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseOut) { self.cellStack2.arrangedSubviews[1].backgroundColor = .clear } } } private func reverseAnimation() { let animator = UIViewPropertyAnimator(duration: 1, timingParameters: timingParams) animator.addAnimations { self.setUnscrolledTransform() self.destinationView.layer.opacity = 0 } animator.addCompletion { [weak self] _ in self?.startAnimation() } animator.startAnimation(afterDelay: 0.5) } private func setUnscrolledTransform() { let totalWidth = 50 * 2 + Self.columnSpacing * 3 let offset = bounds.width - totalWidth let transform = CGAffineTransform(translationX: offset, y: 0) cellStack1.transform = transform cellStack2.transform = transform destinationView.transform = transform } } private class CellView: UIView { init() { super.init(frame: .zero) let avatarView = UIView() avatarView.backgroundColor = .tertiaryLabel avatarView.layer.cornerRadius = 1 avatarView.translatesAutoresizingMaskIntoConstraints = false addSubview(avatarView) let line1 = UIView() line1.backgroundColor = .tertiaryLabel line1.translatesAutoresizingMaskIntoConstraints = false addSubview(line1) let line2 = UIView() line2.backgroundColor = .tertiaryLabel line2.translatesAutoresizingMaskIntoConstraints = false addSubview(line2) let line3 = UIView() line3.backgroundColor = .tertiaryLabel line3.translatesAutoresizingMaskIntoConstraints = false addSubview(line3) NSLayoutConstraint.activate([ avatarView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), avatarView.topAnchor.constraint(equalTo: topAnchor, constant: 4), avatarView.widthAnchor.constraint(equalToConstant: 10), avatarView.heightAnchor.constraint(equalToConstant: 10), line1.topAnchor.constraint(equalTo: avatarView.topAnchor), line1.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 4), line1.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), line1.heightAnchor.constraint(equalToConstant: 4), line2.topAnchor.constraint(equalTo: line1.bottomAnchor, constant: 2), line2.leadingAnchor.constraint(equalTo: line1.leadingAnchor), line2.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -24), line2.heightAnchor.constraint(equalToConstant: 4), line3.topAnchor.constraint(equalTo: line2.bottomAnchor, constant: 2), line3.leadingAnchor.constraint(equalTo: line1.leadingAnchor), line3.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), line3.heightAnchor.constraint(equalToConstant: 4), ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } #Preview { WidescreenNavigationPrefsView() }