From 01124b76a3e40d41e22e6e4a6f57475627e12a64 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 8 Nov 2022 22:14:40 -0500 Subject: [PATCH] Add Duckable package, make Compose screen duckable --- Packages/Duckable/.gitignore | 9 + Packages/Duckable/Package.swift | 31 +++ Packages/Duckable/README.md | 3 + Packages/Duckable/Sources/Duckable/API.swift | 44 ++++ .../Sources/Duckable/DetentIdentifier.swift | 12 + .../Duckable/DuckAnimationController.swift | 89 +++++++ .../DuckableContainerViewController.swift | 217 ++++++++++++++++++ .../DuckedPlaceholderViewController.swift | 71 ++++++ .../Duckable/Sources/Duckable/Swizzler.swift | 33 +++ Tusker.xcodeproj/project.pbxproj | 13 ++ Tusker/Scenes/MainSceneDelegate.swift | 18 +- .../Compose/ComposeHostingController.swift | 31 ++- Tusker/Screens/Compose/ComposeView.swift | 11 +- Tusker/Screens/Main/Duckable+Root.swift | 37 +++ .../Main/MainSplitViewController.swift | 16 +- .../Main/MainTabBarViewController.swift | 16 +- Tusker/TuskerNavigationDelegate.swift | 12 +- 17 files changed, 625 insertions(+), 38 deletions(-) create mode 100644 Packages/Duckable/.gitignore create mode 100644 Packages/Duckable/Package.swift create mode 100644 Packages/Duckable/README.md create mode 100644 Packages/Duckable/Sources/Duckable/API.swift create mode 100644 Packages/Duckable/Sources/Duckable/DetentIdentifier.swift create mode 100644 Packages/Duckable/Sources/Duckable/DuckAnimationController.swift create mode 100644 Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift create mode 100644 Packages/Duckable/Sources/Duckable/DuckedPlaceholderViewController.swift create mode 100644 Packages/Duckable/Sources/Duckable/Swizzler.swift create mode 100644 Tusker/Screens/Main/Duckable+Root.swift diff --git a/Packages/Duckable/.gitignore b/Packages/Duckable/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/Packages/Duckable/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/Duckable/Package.swift b/Packages/Duckable/Package.swift new file mode 100644 index 00000000..2e7a085d --- /dev/null +++ b/Packages/Duckable/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Duckable", + platforms: [ + .iOS(.v15), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "Duckable", + targets: ["Duckable"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "Duckable", + dependencies: []), + .testTarget( + name: "DuckableTests", + dependencies: ["Duckable"]), + ] +) diff --git a/Packages/Duckable/README.md b/Packages/Duckable/README.md new file mode 100644 index 00000000..0b5cbd83 --- /dev/null +++ b/Packages/Duckable/README.md @@ -0,0 +1,3 @@ +# Duckable + +A package that allows modally-presented view controllers to be 'ducked' to make the content behind them accessible (à la Mail.app). diff --git a/Packages/Duckable/Sources/Duckable/API.swift b/Packages/Duckable/Sources/Duckable/API.swift new file mode 100644 index 00000000..cddac680 --- /dev/null +++ b/Packages/Duckable/Sources/Duckable/API.swift @@ -0,0 +1,44 @@ +// +// API.swift +// Duckable +// +// Created by Shadowfacts on 11/7/22. +// + +import UIKit + +public protocol DuckableViewController: UIViewController { + var duckableDelegate: DuckableViewControllerDelegate? { get set } + + func duckableViewControllerMayAttemptToDuck() + + func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) + + func duckableViewControllerDidFinishAnimatingDuck() +} + +extension DuckableViewController { + public func duckableViewControllerMayAttemptToDuck() {} + public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {} + public func duckableViewControllerDidFinishAnimatingDuck() {} +} + +public protocol DuckableViewControllerDelegate: AnyObject { + func duckableViewControllerWillDismiss(animated: Bool) +} + +extension UIViewController { + @available(iOS 16.0, *) + public func presentDuckable(_ viewController: DuckableViewController) -> Bool { + var cur: UIViewController? = self + while let vc = cur { + if let container = vc as? DuckableContainerViewController { + container.presentDuckable(viewController, animated: true, completion: nil) + return true + } else { + cur = vc.parent + } + } + return false + } +} diff --git a/Packages/Duckable/Sources/Duckable/DetentIdentifier.swift b/Packages/Duckable/Sources/Duckable/DetentIdentifier.swift new file mode 100644 index 00000000..18765a7e --- /dev/null +++ b/Packages/Duckable/Sources/Duckable/DetentIdentifier.swift @@ -0,0 +1,12 @@ +// +// DetentIdentifier.swift +// Duckable +// +// Created by Shadowfacts on 11/7/22. +// + +import UIKit + +extension UISheetPresentationController.Detent.Identifier { + static let bottom = Self("\(Bundle.main.bundleIdentifier!).bottom") +} diff --git a/Packages/Duckable/Sources/Duckable/DuckAnimationController.swift b/Packages/Duckable/Sources/Duckable/DuckAnimationController.swift new file mode 100644 index 00000000..38182104 --- /dev/null +++ b/Packages/Duckable/Sources/Duckable/DuckAnimationController.swift @@ -0,0 +1,89 @@ +// +// DuckAnimationController.swift +// Duckable +// +// Created by Shadowfacts on 11/7/22. +// + +import UIKit + +@available(iOS 16.0, *) +class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning { + let owner: DuckableContainerViewController + let needsShrinkAnimation: Bool + + init(owner: DuckableContainerViewController, needsShrinkAnimation: Bool) { + self.owner = owner + self.needsShrinkAnimation = needsShrinkAnimation + } + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.2 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard case .ducked(let duckable, placeholder: let placeholder) = owner.state, + let presented = transitionContext.viewController(forKey: .from) else { + transitionContext.completeTransition(false) + return + } + + guard transitionContext.isAnimated else { + transitionContext.completeTransition(true) + return + } + + let container = transitionContext.containerView + + + if needsShrinkAnimation { + + duckable.duckableViewControllerWillAnimateDuck(withDuration: 0.2, afterDelay: 0.2) + + let presentedFrameInContainer = container.convert(presented.view.bounds, from: presented.view) + let heightToSlide = container.bounds.height - container.safeAreaInsets.bottom - detentHeight - presentedFrameInContainer.minY + + let slideAnimator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) + slideAnimator.addAnimations { + container.transform = CGAffineTransform(translationX: 0, y: heightToSlide + 10) + } + slideAnimator.addCompletion { _ in + duckable.duckableViewControllerDidFinishAnimatingDuck() + transitionContext.completeTransition(true) + } + slideAnimator.startAnimation() + + placeholder.view.transform = CGAffineTransform(translationX: 0, y: 10) + let bounceAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) { + placeholder.view.transform = .identity + container.transform = CGAffineTransform(translationX: 0, y: heightToSlide) + } + bounceAnimator.startAnimation(afterDelay: 0.3) + + let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) { + presented.view.layer.opacity = 0 + } + fadeAnimator.startAnimation(afterDelay: 0.3) + + } else { + + duckable.duckableViewControllerWillAnimateDuck(withDuration: 0.2, afterDelay: 0) + + placeholder.view.transform = CGAffineTransform(translationX: 0, y: 10) + let bounceAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) { + placeholder.view.transform = .identity + container.transform = CGAffineTransform(translationX: 0, y: -10) + } + bounceAnimator.startAnimation(afterDelay: 0.2) + + let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) { + presented.view.layer.opacity = 0 + } + fadeAnimator.addCompletion { _ in + duckable.duckableViewControllerDidFinishAnimatingDuck() + transitionContext.completeTransition(true) + } + fadeAnimator.startAnimation(afterDelay: 0.2) + } + } +} diff --git a/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift b/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift new file mode 100644 index 00000000..45d192dd --- /dev/null +++ b/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift @@ -0,0 +1,217 @@ +// +// DuckableContainerViewController.swift +// Duckable +// +// Created by Shadowfacts on 11/7/22. +// + +import UIKit + +let duckedCornerRadius: CGFloat = 10 +let detentHeight: CGFloat = 44 + +@available(iOS 16.0, *) +public class DuckableContainerViewController: UIViewController, DuckableViewControllerDelegate { + + public let child: UIViewController + private var bottomConstraint: NSLayoutConstraint! + private(set) var state = State.idle + + public init(child: UIViewController) { + self.child = child + + super.init(nibName: nil, bundle: nil) + + swizzleSheetController() + } + + required init?(coder: NSCoder) { + fatalError() + } + + public override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + + child.beginAppearanceTransition(true, animated: false) + addChild(child) + child.didMove(toParent: self) + child.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(child.view) + child.endAppearanceTransition() + + bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + NSLayoutConstraint.activate([ + child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + child.view.topAnchor.constraint(equalTo: view.topAnchor), + bottomConstraint, + ]) + } + + func presentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) { + guard case .idle = state else { + if animated, + case .ducked(_, placeholder: let placeholder) = state { + UIImpactFeedbackGenerator(style: .soft).impactOccurred() + let origConstant = placeholder.topConstraint.constant + UIView.animateKeyframes(withDuration: 0.4, delay: 0) { + UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) { + placeholder.topConstraint.constant = origConstant - 20 + self.view.layoutIfNeeded() + } + UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) { + placeholder.topConstraint.constant = origConstant + self.view.layoutIfNeeded() + } + } + } + return + } + state = .presentingDucked(viewController, isFirstPresentation: true) + doPresentDuckable(viewController, animated: animated, completion: completion) + } + + private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) { + viewController.duckableDelegate = self + let nav = UINavigationController(rootViewController: viewController) + nav.modalPresentationStyle = .custom + nav.transitioningDelegate = self + present(nav, animated: animated) { + self.bottomConstraint.isActive = false + self.bottomConstraint = self.child.view.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4) + self.bottomConstraint.isActive = true + completion?() + } + } + + public func duckableViewControllerWillDismiss(animated: Bool) { + state = .idle + bottomConstraint.isActive = false + bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + bottomConstraint.isActive = true + child.view.layer.cornerRadius = 0 + setOverrideTraitCollection(nil, forChild: child) + } + + func createPlaceholderForDuckedViewController(_ viewController: DuckableViewController) -> DuckedPlaceholderViewController { + let placeholder = DuckedPlaceholderViewController(for: viewController, owner: self) + placeholder.view.translatesAutoresizingMaskIntoConstraints = false + + placeholder.beginAppearanceTransition(true, animated: false) + self.addChild(placeholder) + placeholder.didMove(toParent: self) + self.view.addSubview(placeholder.view) + placeholder.endAppearanceTransition() + + let placeholderTopConstraint = placeholder.view.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight) + placeholder.topConstraint = placeholderTopConstraint + NSLayoutConstraint.activate([ + placeholder.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + placeholder.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + placeholder.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + placeholderTopConstraint + ]) + + // otherwise the layout changes get lumped in with the system animation + UIView.performWithoutAnimation { + self.view.layoutIfNeeded() + } + return placeholder + } + + func duckViewController() { + guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else { + return + } + let placeholder = createPlaceholderForDuckedViewController(viewController) + state = .ducked(viewController, placeholder: placeholder) + child.view.layer.cornerRadius = duckedCornerRadius + child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + child.view.layer.masksToBounds = true + dismiss(animated: true) + } + + @objc func unduckViewController() { + guard case .ducked(let viewController, placeholder: let placeholder) = state else { + return + } + state = .presentingDucked(viewController, isFirstPresentation: false) + doPresentDuckable(viewController, animated: true) { + placeholder.view.removeFromSuperview() + placeholder.willMove(toParent: nil) + placeholder.removeFromParent() + } + } + + func sheetOffsetDidChange() { + if case .presentingDucked(let duckable, isFirstPresentation: _) = state { + duckable.duckableViewControllerMayAttemptToDuck() + } + } + + enum State { + case idle + case presentingDucked(DuckableViewController, isFirstPresentation: Bool) + case ducked(DuckableViewController, placeholder: DuckedPlaceholderViewController) + } + +} + +@available(iOS 16.0, *) +extension DuckableContainerViewController: UIViewControllerTransitioningDelegate { + public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { + let controller = UISheetPresentationController(presentedViewController: presented, presenting: presenting) + controller.delegate = self + controller.prefersGrabberVisible = true + controller.selectedDetentIdentifier = .large + controller.largestUndimmedDetentIdentifier = .bottom + controller.detents = [ + .custom(identifier: .bottom, resolver: { context in + return detentHeight + }), + .large(), + ] + return controller + } + + public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + if case .ducked(_, placeholder: _) = state { + return DuckAnimationController( + owner: self, + needsShrinkAnimation: isDetentChangingDueToGrabberAction + ) + } else { + return nil + } + } +} + +@available(iOS 16.0, *) +extension DuckableContainerViewController: UISheetPresentationControllerDelegate { + public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) { + let snapshot = child.view.snapshotView(afterScreenUpdates: false)! + snapshot.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(snapshot) + NSLayoutConstraint.activate([ + snapshot.leadingAnchor.constraint(equalTo: child.view.leadingAnchor), + snapshot.trailingAnchor.constraint(equalTo: child.view.trailingAnchor), + snapshot.topAnchor.constraint(equalTo: child.view.topAnchor), + snapshot.bottomAnchor.constraint(equalTo: child.view.bottomAnchor), + ]) + setOverrideTraitCollection(UITraitCollection(userInterfaceLevel: .elevated), forChild: child) + transitionCoordinator!.animate { context in + snapshot.layer.opacity = 0 + } completion: { _ in + snapshot.removeFromSuperview() + } + } + + public func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) { + if sheetPresentationController.selectedDetentIdentifier == .bottom { + duckViewController() + } + } +} + diff --git a/Packages/Duckable/Sources/Duckable/DuckedPlaceholderViewController.swift b/Packages/Duckable/Sources/Duckable/DuckedPlaceholderViewController.swift new file mode 100644 index 00000000..d1390a71 --- /dev/null +++ b/Packages/Duckable/Sources/Duckable/DuckedPlaceholderViewController.swift @@ -0,0 +1,71 @@ +// +// DuckedPlaceholderView.swift +// Duckable +// +// Created by Shadowfacts on 11/7/22. +// + +import UIKit + +@available(iOS 16.0, *) +class DuckedPlaceholderViewController: UIViewController { + private unowned let owner: DuckableContainerViewController + private let navBar = UINavigationBar() + + var topConstraint: NSLayoutConstraint! + + init(for duckableViewController: DuckableViewController, owner: DuckableContainerViewController) { + self.owner = owner + + super.init(nibName: nil, bundle: nil) + + let item = UINavigationItem() + item.title = duckableViewController.navigationItem.title + item.titleView = duckableViewController.navigationItem.titleView + navBar.setItems([item], animated: false) + } + + required init?(coder: NSCoder) { + fatalError() + } + + override func viewDidLoad() { + super.viewDidLoad() + + setBackgroundColor() + view.layer.cornerRadius = duckedCornerRadius + view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = 0.05 + + view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(placeholderTapped))) + + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + navBar.standardAppearance = appearance + navBar.isUserInteractionEnabled = false + navBar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(navBar) + NSLayoutConstraint.activate([ + navBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + navBar.topAnchor.constraint(equalTo: view.topAnchor), + ]) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setBackgroundColor() + } + + private func setBackgroundColor() { + // when just using .systemBackground and setting the override trait collection for the placeholder VC, + // the color doesn't change until after the dismiss animation occurs (but only when tapping the grabber to duck, not when swiping) + view.backgroundColor = .systemBackground.resolvedColor(with: UITraitCollection(traitsFrom: [traitCollection, UITraitCollection(userInterfaceLevel: .elevated)])) + } + + @objc private func placeholderTapped() { + owner.unduckViewController() + } +} diff --git a/Packages/Duckable/Sources/Duckable/Swizzler.swift b/Packages/Duckable/Sources/Duckable/Swizzler.swift new file mode 100644 index 00000000..1e26bbf8 --- /dev/null +++ b/Packages/Duckable/Sources/Duckable/Swizzler.swift @@ -0,0 +1,33 @@ +// +// Swizzler.swift +// Duckable +// +// Created by Shadowfacts on 11/7/22. +// + +import UIKit +import os.log + +private var hasInitialized = false +var isDetentChangingDueToGrabberAction = false + +@available(iOS 16.0, *) +func swizzleSheetController() { + guard !hasInitialized else { + return + } + hasInitialized = true + + var originalIMP: IMP? + let imp = imp_implementationWithBlock({ (self: UISheetPresentationController, param: AnyObject) in + let original = unsafeBitCast(originalIMP!, to: (@convention(c) (UISheetPresentationController, AnyObject) -> Void).self) + isDetentChangingDueToGrabberAction = true + original(self, param) + isDetentChangingDueToGrabberAction = false + } as @convention(block) (UISheetPresentationController, AnyObject) -> Void) + let sel = [":", "PrimaryAction", "GrabberDidTrigger", "dropShadowView", "_"].reversed().joined() + originalIMP = class_replaceMethod(UISheetPresentationController.self, Selector(sel), imp, "v@:@") + if originalIMP == nil { + os_log(.fault, log: .default, "Unable to initialize Duckable grabber tap hook") + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 504e44c0..95b79dfb 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -255,6 +255,8 @@ D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; }; D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */; }; + D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; }; + D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; }; D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; }; D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; @@ -611,6 +613,8 @@ D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = ""; }; D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = ""; }; D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = ""; }; + D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = ""; }; + D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = ""; }; D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = ""; }; D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = ""; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = ""; }; @@ -689,6 +693,7 @@ D6552367289870790048A653 /* ScreenCorners in Frameworks */, D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */, D63CC702290EC0B8000E19DE /* Sentry in Frameworks */, + D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -935,6 +940,7 @@ isa = PBXGroup; children = ( D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */, + D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */, D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */, 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */, D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */, @@ -1362,6 +1368,7 @@ children = ( D63CC703290EC472000E19DE /* Dist.xcconfig */, D674A50727F910F300BA03AC /* Pachyderm */, + D6BEA243291A0C83002F4D01 /* Duckable */, D6D4DDCE212518A000E1C4BB /* Tusker */, D6D4DDE3212518A200E1C4BB /* TuskerTests */, D6D4DDEE212518A200E1C4BB /* TuskerUITests */, @@ -1528,6 +1535,7 @@ D674A50827F9128D00BA03AC /* Pachyderm */, D6552366289870790048A653 /* ScreenCorners */, D63CC701290EC0B8000E19DE /* Sentry */, + D6BEA244291A0EDE002F4D01 /* Duckable */, ); productName = Tusker; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; @@ -1990,6 +1998,7 @@ D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */, D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */, D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */, + D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */, 04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */, D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */, D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */, @@ -2693,6 +2702,10 @@ isa = XCSwiftPackageProductDependency; productName = Pachyderm; }; + D6BEA244291A0EDE002F4D01 /* Duckable */ = { + isa = XCSwiftPackageProductDependency; + productName = Duckable; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index 29642a73..b14504c9 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -10,6 +10,7 @@ import UIKit import Pachyderm import MessageUI import CoreData +import Duckable class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate { @@ -31,10 +32,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate window = UIWindow(windowScene: windowScene) - showAppOrOnboardingUI(session: session) - if connectionOptions.urlContexts.count > 0 { - self.scene(scene, openURLContexts: connectionOptions.urlContexts) - } + showAppOrOnboardingUI(session: session) + if connectionOptions.urlContexts.count > 0 { + self.scene(scene, openURLContexts: connectionOptions.urlContexts) + } window!.makeKeyAndVisible() @@ -205,7 +206,14 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate mastodonController.getOwnAccount() mastodonController.getOwnInstance() - return MainSplitViewController(mastodonController: mastodonController) + let split = MainSplitViewController(mastodonController: mastodonController) + if UIDevice.current.userInterfaceIdiom == .phone, + #available(iOS 16.0, *) { + // TODO: maybe the duckable container should be outside the account switching container + return DuckableContainerViewController(child: split) + } else { + return split + } } func createOnboardingUI() -> UIViewController { diff --git a/Tusker/Screens/Compose/ComposeHostingController.swift b/Tusker/Screens/Compose/ComposeHostingController.swift index 4bb5ff2d..fa9aa989 100644 --- a/Tusker/Screens/Compose/ComposeHostingController.swift +++ b/Tusker/Screens/Compose/ComposeHostingController.swift @@ -10,14 +10,16 @@ import SwiftUI import Combine import Pachyderm import PencilKit +import Duckable protocol ComposeHostingControllerDelegate: AnyObject { func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool } -class ComposeHostingController: UIHostingController { +class ComposeHostingController: UIHostingController, DuckableViewController { weak var delegate: ComposeHostingControllerDelegate? + weak var duckableDelegate: DuckableViewControllerDelegate? let mastodonController: MastodonController @@ -103,16 +105,15 @@ class ComposeHostingController: UIHostingController { fatalError("init(coder:) has not been implemented") } - override func didMove(toParent parent: UIViewController?) { - super.didMove(toParent: parent) + override func willMove(toParent parent: UIViewController?) { + super.willMove(toParent: parent) if let parent = parent { parent.view.addSubview(mainToolbar) NSLayoutConstraint.activate([ - mainToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor), - mainToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor), - // use the top anchor of the toolbar so our additionalSafeAreaInsets (which has the bottom as the toolbar height) don't affect it - mainToolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + mainToolbar.leadingAnchor.constraint(equalTo: parent.view.leadingAnchor), + mainToolbar.trailingAnchor.constraint(equalTo: parent.view.trailingAnchor), + mainToolbar.bottomAnchor.constraint(equalTo: parent.view.safeAreaLayoutGuide.bottomAnchor), ]) } } @@ -301,6 +302,19 @@ class ComposeHostingController: UIHostingController { } } + // MARK: Duckable + + func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) { + let animator = UIViewPropertyAnimator(duration: duration, curve: .linear) { + self.mainToolbar.layer.opacity = 0 + } + animator.startAnimation(afterDelay: delay) + } + + func duckableViewControllerDidFinishAnimatingDuck() { + mainToolbar.layer.opacity = 1 + } + // MARK: Interaction @objc func cwButtonPressed() { @@ -335,6 +349,7 @@ extension ComposeHostingController: ComposeUIStateDelegate { let dismissed = delegate?.dismissCompose(mode: mode) ?? false if !dismissed { self.dismiss(animated: true) + self.duckableDelegate?.duckableViewControllerWillDismiss(animated: true) } } @@ -424,6 +439,8 @@ extension ComposeHostingController: DraftsTableViewControllerDelegate { } } +// superseded by duckable stuff +@available(iOS, obsoleted: 16.0) extension ComposeHostingController: UIAdaptivePresentationControllerDelegate { func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { return Preferences.shared.automaticallySaveDrafts || !draft.hasContent diff --git a/Tusker/Screens/Compose/ComposeView.swift b/Tusker/Screens/Compose/ComposeView.swift index dacd663a..5b13310b 100644 --- a/Tusker/Screens/Compose/ComposeView.swift +++ b/Tusker/Screens/Compose/ComposeView.swift @@ -97,7 +97,7 @@ struct ComposeView: View { globalFrameOutsideList = frame } }) - .navigationBarTitle("Compose") + .navigationTitle(navTitle) .actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet) .alert(isPresented: $isShowingPostErrorAlert) { Alert( @@ -180,6 +180,15 @@ struct ComposeView: View { }.frame(height: 50) } + private var navTitle: Text { + if let id = draft.inReplyToID, + let status = mastodonController.persistentContainer.status(for: id) { + return Text("Reply to @\(status.account.acct)") + } else { + return Text("New Post") + } + } + private var cancelButton: some View { Button(action: self.cancel) { Text("Cancel") diff --git a/Tusker/Screens/Main/Duckable+Root.swift b/Tusker/Screens/Main/Duckable+Root.swift new file mode 100644 index 00000000..9e94108d --- /dev/null +++ b/Tusker/Screens/Main/Duckable+Root.swift @@ -0,0 +1,37 @@ +// +// Duckable+Root.swift +// Tusker +// +// Created by Shadowfacts on 11/7/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import Duckable + +@available(iOS 16.0, *) +extension DuckableContainerViewController: TuskerRootViewController { + func presentCompose() { + (child as? TuskerRootViewController)?.presentCompose() + } + + func select(tab: MainTabBarViewController.Tab) { + (child as? TuskerRootViewController)?.select(tab: tab) + } + + func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? { + return (child as? TuskerRootViewController)?.getTabController(tab: tab) + } + + func performSearch(query: String) { + (child as? TuskerRootViewController)?.performSearch(query: query) + } + + func presentPreferences(completion: (() -> Void)?) { + (child as? TuskerRootViewController)?.presentPreferences(completion: completion) + } + + func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { + (child as? TuskerRootViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue + } +} diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index 09545ddb..79644a7e 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -373,19 +373,13 @@ fileprivate extension MainSidebarViewController.Item { } } +extension MainSplitViewController: TuskerNavigationDelegate { + var apiController: MastodonController! { mastodonController } +} + extension MainSplitViewController: TuskerRootViewController { @objc func presentCompose() { - if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { - let compose = UserActivityManager.newPostActivity(mentioning: nil, accountID: mastodonController.accountInfo!.id) - let options = UIWindowScene.ActivationRequestOptions() - options.preferredPresentationStyle = .prominent - UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil) - } else { - let vc = ComposeHostingController(mastodonController: mastodonController) - let nav = EnhancedNavigationViewController(rootViewController: vc) - nav.presentationController?.delegate = vc - present(nav, animated: true) - } + self.compose() } func select(tab: MainTabBarViewController.Tab) { diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index a508706a..f4026d21 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -228,19 +228,13 @@ extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate { } } +extension MainTabBarViewController: TuskerNavigationDelegate { + var apiController: MastodonController! { mastodonController } +} + extension MainTabBarViewController: TuskerRootViewController { @objc func presentCompose() { - if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { - let compose = UserActivityManager.newPostActivity(mentioning: nil, accountID: mastodonController.accountInfo!.id) - let options = UIWindowScene.ActivationRequestOptions() - options.preferredPresentationStyle = .prominent - UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil) - } else { - let vc = ComposeHostingController(mastodonController: mastodonController) - let nav = EnhancedNavigationViewController(rootViewController: vc) - nav.presentationController?.delegate = vc - present(nav, animated: true) - } + compose() } func select(tab: Tab) { diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index b6e3148d..64ee7c2c 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -96,9 +96,15 @@ extension TuskerNavigationDelegate { UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil) } else { let compose = ComposeHostingController(draft: draft, mastodonController: apiController) - let nav = UINavigationController(rootViewController: compose) - nav.presentationController?.delegate = compose - present(nav, animated: true) + if #available(iOS 16.0, *), + presentDuckable(compose) { + return + } else { + let compose = ComposeHostingController(draft: draft, mastodonController: apiController) + let nav = UINavigationController(rootViewController: compose) + nav.presentationController?.delegate = compose + present(nav, animated: true) + } } }