Tusker/Tusker/Screens/Preferences/WidescreenNavigationPrefsVi...

467 lines
18 KiB
Swift

//
// 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<Void, Never>()
var body: some View {
HStack {
Spacer()
OptionView<StackNavigationPreview>(
value: .stack,
selection: $preferences.widescreenNavigationMode,
startAnimation: startAnimation
) {
Text("Stack")
}
Spacer(minLength: 32)
OptionView<SplitNavigationPreview>(
value: .splitScreen,
selection: $preferences.widescreenNavigationMode,
startAnimation: startAnimation
) {
Text("Split Screen")
}
if preferences.hasFeatureFlag(.iPadMultiColumn) {
Spacer(minLength: 32)
OptionView<MultiColumnNavigationPreview>(
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<Content: NavigationModePreview>: View {
let value: Preferences.WidescreenNavigationMode
@Binding var selection: Preferences.WidescreenNavigationMode
let startAnimation: PassthroughSubject<Void, Never>
@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<Content>(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<Void, Never>)
}
private struct NavigationModeRepresentable<UIViewType: NavigationModePreview>: UIViewRepresentable {
let startAnimation: PassthroughSubject<Void, Never>
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<Void, Never>) {
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<Void, Never>) {
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<Void, Never>) {
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()
}