Add Duckable package, make Compose screen duckable
This commit is contained in:
parent
7600954f4b
commit
01124b76a3
|
@ -0,0 +1,9 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
|
@ -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"]),
|
||||
]
|
||||
)
|
|
@ -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).
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -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 */
|
||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
|||
import Pachyderm
|
||||
import MessageUI
|
||||
import CoreData
|
||||
import Duckable
|
||||
|
||||
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate {
|
||||
|
||||
|
@ -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 {
|
||||
|
|
|
@ -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<ComposeContainerView> {
|
||||
class ComposeHostingController: UIHostingController<ComposeContainerView>, DuckableViewController {
|
||||
|
||||
weak var delegate: ComposeHostingControllerDelegate?
|
||||
weak var duckableDelegate: DuckableViewControllerDelegate?
|
||||
|
||||
let mastodonController: MastodonController
|
||||
|
||||
|
@ -103,16 +105,15 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
|||
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<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
|
||||
|
||||
@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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -94,6 +94,11 @@ extension TuskerNavigationDelegate {
|
|||
let options = UIWindowScene.ActivationRequestOptions()
|
||||
options.preferredPresentationStyle = .prominent
|
||||
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 {
|
||||
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
|
||||
let nav = UINavigationController(rootViewController: compose)
|
||||
|
@ -101,6 +106,7 @@ extension TuskerNavigationDelegate {
|
|||
present(nav, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
|
||||
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
|
||||
|
|
Loading…
Reference in New Issue