Add Duckable package, make Compose screen duckable

This commit is contained in:
Shadowfacts 2022-11-08 22:14:40 -05:00
parent 7600954f4b
commit 01124b76a3
17 changed files with 625 additions and 38 deletions

9
Packages/Duckable/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -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"]),
]
)

View File

@ -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).

View File

@ -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
}
}

View File

@ -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")
}

View File

@ -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)
}
}
}

View File

@ -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()
}
}
}

View File

@ -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()
}
}

View File

@ -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")
}
}

View File

@ -255,6 +255,8 @@
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; }; D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */; };
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; };
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.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 */; }; D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */; };
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; }; D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */; };
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.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 = "<group>"; }; D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsMode.swift; sourceTree = "<group>"; };
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; }; D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; };
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; }; D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = "<group>"; };
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = "<group>"; };
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; }; D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusTableViewCell.swift; sourceTree = "<group>"; };
D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; }; D6C143D9253510F4007DC240 /* ComposeEmojiTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeEmojiTextField.swift; sourceTree = "<group>"; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
@ -689,6 +693,7 @@
D6552367289870790048A653 /* ScreenCorners in Frameworks */, D6552367289870790048A653 /* ScreenCorners in Frameworks */,
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */, D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
D63CC702290EC0B8000E19DE /* Sentry in Frameworks */, D63CC702290EC0B8000E19DE /* Sentry in Frameworks */,
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -935,6 +940,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */, D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */,
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */,
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */, D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */,
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */, 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */,
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */, D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */,
@ -1362,6 +1368,7 @@
children = ( children = (
D63CC703290EC472000E19DE /* Dist.xcconfig */, D63CC703290EC472000E19DE /* Dist.xcconfig */,
D674A50727F910F300BA03AC /* Pachyderm */, D674A50727F910F300BA03AC /* Pachyderm */,
D6BEA243291A0C83002F4D01 /* Duckable */,
D6D4DDCE212518A000E1C4BB /* Tusker */, D6D4DDCE212518A000E1C4BB /* Tusker */,
D6D4DDE3212518A200E1C4BB /* TuskerTests */, D6D4DDE3212518A200E1C4BB /* TuskerTests */,
D6D4DDEE212518A200E1C4BB /* TuskerUITests */, D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
@ -1528,6 +1535,7 @@
D674A50827F9128D00BA03AC /* Pachyderm */, D674A50827F9128D00BA03AC /* Pachyderm */,
D6552366289870790048A653 /* ScreenCorners */, D6552366289870790048A653 /* ScreenCorners */,
D63CC701290EC0B8000E19DE /* Sentry */, D63CC701290EC0B8000E19DE /* Sentry */,
D6BEA244291A0EDE002F4D01 /* Duckable */,
); );
productName = Tusker; productName = Tusker;
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
@ -1990,6 +1998,7 @@
D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */, D6895DC428D65342006341DA /* ConfirmReblogStatusPreviewView.swift in Sources */,
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */, D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */, D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */,
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */,
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */, 04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */, D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */, D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
@ -2693,6 +2702,10 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = Pachyderm; productName = Pachyderm;
}; };
D6BEA244291A0EDE002F4D01 /* Duckable */ = {
isa = XCSwiftPackageProductDependency;
productName = Duckable;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */ /* Begin XCVersionGroup section */

View File

@ -10,6 +10,7 @@ import UIKit
import Pachyderm import Pachyderm
import MessageUI import MessageUI
import CoreData import CoreData
import Duckable
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate { class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
@ -205,7 +206,14 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
mastodonController.getOwnAccount() mastodonController.getOwnAccount()
mastodonController.getOwnInstance() 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 { func createOnboardingUI() -> UIViewController {

View File

@ -10,14 +10,16 @@ import SwiftUI
import Combine import Combine
import Pachyderm import Pachyderm
import PencilKit import PencilKit
import Duckable
protocol ComposeHostingControllerDelegate: AnyObject { protocol ComposeHostingControllerDelegate: AnyObject {
func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool
} }
class ComposeHostingController: UIHostingController<ComposeContainerView> { class ComposeHostingController: UIHostingController<ComposeContainerView>, DuckableViewController {
weak var delegate: ComposeHostingControllerDelegate? weak var delegate: ComposeHostingControllerDelegate?
weak var duckableDelegate: DuckableViewControllerDelegate?
let mastodonController: MastodonController let mastodonController: MastodonController
@ -103,16 +105,15 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func didMove(toParent parent: UIViewController?) { override func willMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent) super.willMove(toParent: parent)
if let parent = parent { if let parent = parent {
parent.view.addSubview(mainToolbar) parent.view.addSubview(mainToolbar)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
mainToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor), mainToolbar.leadingAnchor.constraint(equalTo: parent.view.leadingAnchor),
mainToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor), mainToolbar.trailingAnchor.constraint(equalTo: parent.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.bottomAnchor.constraint(equalTo: parent.view.safeAreaLayoutGuide.bottomAnchor),
mainToolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
]) ])
} }
} }
@ -301,6 +302,19 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
} }
} }
// 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 // MARK: Interaction
@objc func cwButtonPressed() { @objc func cwButtonPressed() {
@ -335,6 +349,7 @@ extension ComposeHostingController: ComposeUIStateDelegate {
let dismissed = delegate?.dismissCompose(mode: mode) ?? false let dismissed = delegate?.dismissCompose(mode: mode) ?? false
if !dismissed { if !dismissed {
self.dismiss(animated: true) 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 { extension ComposeHostingController: UIAdaptivePresentationControllerDelegate {
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return Preferences.shared.automaticallySaveDrafts || !draft.hasContent return Preferences.shared.automaticallySaveDrafts || !draft.hasContent

View File

@ -97,7 +97,7 @@ struct ComposeView: View {
globalFrameOutsideList = frame globalFrameOutsideList = frame
} }
}) })
.navigationBarTitle("Compose") .navigationTitle(navTitle)
.actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet) .actionSheet(isPresented: $uiState.isShowingSaveDraftSheet, content: self.saveAndCloseSheet)
.alert(isPresented: $isShowingPostErrorAlert) { .alert(isPresented: $isShowingPostErrorAlert) {
Alert( Alert(
@ -180,6 +180,15 @@ struct ComposeView: View {
}.frame(height: 50) }.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 { private var cancelButton: some View {
Button(action: self.cancel) { Button(action: self.cancel) {
Text("Cancel") Text("Cancel")

View File

@ -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
}
}

View File

@ -373,19 +373,13 @@ fileprivate extension MainSidebarViewController.Item {
} }
} }
extension MainSplitViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension MainSplitViewController: TuskerRootViewController { extension MainSplitViewController: TuskerRootViewController {
@objc func presentCompose() { @objc func presentCompose() {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { self.compose()
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)
}
} }
func select(tab: MainTabBarViewController.Tab) { func select(tab: MainTabBarViewController.Tab) {

View File

@ -228,19 +228,13 @@ extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
} }
} }
extension MainTabBarViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension MainTabBarViewController: TuskerRootViewController { extension MainTabBarViewController: TuskerRootViewController {
@objc func presentCompose() { @objc func presentCompose() {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { compose()
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)
}
} }
func select(tab: Tab) { func select(tab: Tab) {

View File

@ -94,6 +94,11 @@ extension TuskerNavigationDelegate {
let options = UIWindowScene.ActivationRequestOptions() let options = UIWindowScene.ActivationRequestOptions()
options.preferredPresentationStyle = .prominent options.preferredPresentationStyle = .prominent
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil) UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil)
} else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
if #available(iOS 16.0, *),
presentDuckable(compose) {
return
} else { } else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController) let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
let nav = UINavigationController(rootViewController: compose) let nav = UINavigationController(rootViewController: compose)
@ -101,6 +106,7 @@ extension TuskerNavigationDelegate {
present(nav, animated: true) present(nav, animated: true)
} }
} }
}
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) { func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct) let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)