Merge branch 'develop' into compose-redesign
This commit is contained in:
commit
c564bb4112
11
CHANGELOG.md
11
CHANGELOG.md
|
@ -1,5 +1,16 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2024.5 (137)
|
||||||
|
Features/Improvements:
|
||||||
|
- Improve gallery presentation/dismissal transitions
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Account for bidirectional text in display names
|
||||||
|
- Fix crash when playing back gifv
|
||||||
|
- Fix gallery controls not hiding if video loading fails
|
||||||
|
- iPadOS: Fix incorrect gallery dismiss animation on non-fullscreen windows
|
||||||
|
- iPadOS: Fix hang when switching accounts
|
||||||
|
|
||||||
## 2024.4 (136)
|
## 2024.4 (136)
|
||||||
Features/Improvements:
|
Features/Improvements:
|
||||||
- Import image description when adding attachments from Photos if possible
|
- Import image description when adding attachments from Photos if possible
|
||||||
|
|
|
@ -82,8 +82,8 @@ public class FallbackGalleryNavigationController: UINavigationController, Galler
|
||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
public var canAnimateFromSourceView: Bool {
|
public var presentationAnimation: GalleryContentPresentationAnimation {
|
||||||
false
|
.fade
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -97,6 +97,10 @@ open class ImageGalleryContentViewController: UIViewController, GalleryContentVi
|
||||||
return [image]
|
return [image]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var presentationAnimation: GalleryContentPresentationAnimation {
|
||||||
|
gifController != nil ? .fromSourceViewWithoutSnapshot : .fromSourceView
|
||||||
|
}
|
||||||
|
|
||||||
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||||
if #available(iOS 16.0, macCatalyst 17.0, *),
|
if #available(iOS 16.0, macCatalyst 17.0, *),
|
||||||
let analysisInteraction {
|
let analysisInteraction {
|
||||||
|
|
|
@ -27,8 +27,8 @@ public class LoadingGalleryContentViewController: UIViewController, GalleryConte
|
||||||
wrapped?.caption ?? fallbackCaption
|
wrapped?.caption ?? fallbackCaption
|
||||||
}
|
}
|
||||||
|
|
||||||
public var canAnimateFromSourceView: Bool {
|
public var presentationAnimation: GalleryContentPresentationAnimation {
|
||||||
wrapped?.canAnimateFromSourceView ?? true
|
wrapped?.presentationAnimation ?? .fade
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
|
public init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
|
||||||
|
|
|
@ -20,6 +20,7 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
|
||||||
private var statusObservation: NSKeyValueObservation?
|
private var statusObservation: NSKeyValueObservation?
|
||||||
private var rateObservation: NSKeyValueObservation?
|
private var rateObservation: NSKeyValueObservation?
|
||||||
private var hideControlsWorkItem: DispatchWorkItem?
|
private var hideControlsWorkItem: DispatchWorkItem?
|
||||||
|
private var isShowingError = false
|
||||||
|
|
||||||
public init(url: URL, caption: String?) {
|
public init(url: URL, caption: String?) {
|
||||||
self.url = url
|
self.url = url
|
||||||
|
@ -90,12 +91,15 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
|
||||||
self.container?.setGalleryContentLoading(false)
|
self.container?.setGalleryContentLoading(false)
|
||||||
self.showErrorView(error)
|
self.showErrorView(error)
|
||||||
self.statusObservation = nil
|
self.statusObservation = nil
|
||||||
|
self.overlayVC.setVisible(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showErrorView(_ error: any Error) {
|
private func showErrorView(_ error: any Error) {
|
||||||
|
isShowingError = true
|
||||||
|
|
||||||
let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!)
|
let image = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")!)
|
||||||
image.tintColor = .secondaryLabel
|
image.tintColor = .secondaryLabel
|
||||||
image.contentMode = .scaleAspectFit
|
image.contentMode = .scaleAspectFit
|
||||||
|
@ -156,6 +160,10 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
|
||||||
[]
|
[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var presentationAnimation: GalleryContentPresentationAnimation {
|
||||||
|
isShowingError ? .fade : .fromSourceViewWithoutSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
private lazy var overlayVC = VideoOverlayViewController(player: player)
|
private lazy var overlayVC = VideoOverlayViewController(player: player)
|
||||||
public var contentOverlayAccessoryViewController: UIViewController? {
|
public var contentOverlayAccessoryViewController: UIViewController? {
|
||||||
overlayVC
|
overlayVC
|
||||||
|
@ -164,7 +172,9 @@ open class VideoGalleryContentViewController: UIViewController, GalleryContentVi
|
||||||
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
|
public private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
|
||||||
|
|
||||||
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
public func setControlsVisible(_ visible: Bool, animated: Bool, dueToUserInteraction: Bool) {
|
||||||
overlayVC.setVisible(visible)
|
if !isShowingError {
|
||||||
|
overlayVC.setVisible(visible)
|
||||||
|
}
|
||||||
|
|
||||||
if !visible {
|
if !visible {
|
||||||
hideControlsWorkItem?.cancel()
|
hideControlsWorkItem?.cancel()
|
||||||
|
@ -207,9 +217,9 @@ private class PlayerView: UIView {
|
||||||
playerLayer.player = player
|
playerLayer.player = player
|
||||||
playerLayer.videoGravity = .resizeAspect
|
playerLayer.videoGravity = .resizeAspect
|
||||||
|
|
||||||
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] _, _ in
|
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [weak self] _, _ in
|
||||||
MainActor.assumeIsolated {
|
MainActor.assumeIsolated {
|
||||||
self.invalidateIntrinsicContentSize()
|
self?.invalidateIntrinsicContentSize()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ public protocol GalleryContentViewController: UIViewController {
|
||||||
var contentOverlayAccessoryViewController: UIViewController? { get }
|
var contentOverlayAccessoryViewController: UIViewController? { get }
|
||||||
var bottomControlsAccessoryViewController: UIViewController? { get }
|
var bottomControlsAccessoryViewController: UIViewController? { get }
|
||||||
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool { get }
|
var insetBottomControlsAccessoryViewControllerToSafeArea: Bool { get }
|
||||||
var canAnimateFromSourceView: Bool { get }
|
var presentationAnimation: GalleryContentPresentationAnimation { get }
|
||||||
var hideControlsOnZoom: Bool { get }
|
var hideControlsOnZoom: Bool { get }
|
||||||
|
|
||||||
func shouldHideControls() -> Bool
|
func shouldHideControls() -> Bool
|
||||||
|
@ -38,8 +38,8 @@ public extension GalleryContentViewController {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
var canAnimateFromSourceView: Bool {
|
var presentationAnimation: GalleryContentPresentationAnimation {
|
||||||
true
|
.fromSourceView
|
||||||
}
|
}
|
||||||
|
|
||||||
var hideControlsOnZoom: Bool {
|
var hideControlsOnZoom: Bool {
|
||||||
|
@ -59,3 +59,9 @@ public extension GalleryContentViewController {
|
||||||
func galleryContentWillDisappear() {
|
func galleryContentWillDisappear() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum GalleryContentPresentationAnimation {
|
||||||
|
case fade
|
||||||
|
case fromSourceView
|
||||||
|
case fromSourceViewWithoutSnapshot
|
||||||
|
}
|
||||||
|
|
|
@ -30,12 +30,37 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
||||||
|
|
||||||
let itemViewController = from.currentItemViewController
|
let itemViewController = from.currentItemViewController
|
||||||
|
|
||||||
if !itemViewController.content.canAnimateFromSourceView || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
|
if itemViewController.content.presentationAnimation == .fade || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
|
||||||
animateCrossFadeTransition(using: transitionContext)
|
animateCrossFadeTransition(using: transitionContext)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let container = transitionContext.containerView
|
let container = transitionContext.containerView
|
||||||
|
|
||||||
|
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
|
||||||
|
// is in the window's root presentation.
|
||||||
|
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
|
||||||
|
// `to.view` is already in the view hierarchy at this point; and adding it to the
|
||||||
|
// container causees it to be removed when the transition completes.
|
||||||
|
if to.view.superview == nil {
|
||||||
|
to.view.frame = container.bounds
|
||||||
|
container.addSubview(to.view)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot {
|
||||||
|
nil
|
||||||
|
} else {
|
||||||
|
sourceView.snapshotView(afterScreenUpdates: false)
|
||||||
|
}
|
||||||
|
if let sourceSnapshot {
|
||||||
|
let snapshotContainer = sourceView.ancestorForInsertingSnapshot
|
||||||
|
snapshotContainer.addSubview(sourceSnapshot)
|
||||||
|
let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView)
|
||||||
|
sourceSnapshot.frame = sourceFrameInShapshotContainer
|
||||||
|
sourceSnapshot.layer.opacity = 1
|
||||||
|
self.sourceView.layer.opacity = 0
|
||||||
|
}
|
||||||
|
|
||||||
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
|
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
|
||||||
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
|
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
|
||||||
|
|
||||||
|
@ -48,38 +73,39 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
||||||
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
|
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
|
||||||
.scaledBy(x: scale, y: scale)
|
.scaledBy(x: scale, y: scale)
|
||||||
sourceView.transform = sourceToDestTransform
|
sourceView.transform = sourceToDestTransform
|
||||||
|
sourceSnapshot?.transform = sourceToDestTransform
|
||||||
} else {
|
} else {
|
||||||
appliedSourceToDestTransform = false
|
appliedSourceToDestTransform = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
|
|
||||||
// is in the window's root presentation.
|
|
||||||
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
|
|
||||||
// `to.view` is already in the view hierarchy at this point; and adding it to the
|
|
||||||
// container causees it to be removed when the transition completes.
|
|
||||||
if to.view.superview == nil {
|
|
||||||
to.view.frame = container.bounds
|
|
||||||
container.addSubview(to.view)
|
|
||||||
}
|
|
||||||
|
|
||||||
from.view.frame = container.bounds
|
from.view.frame = container.bounds
|
||||||
container.addSubview(from.view)
|
container.addSubview(from.view)
|
||||||
|
|
||||||
|
let contentContainer = UIView()
|
||||||
|
contentContainer.layer.masksToBounds = true
|
||||||
|
contentContainer.frame = destFrameInContainer
|
||||||
|
container.addSubview(contentContainer)
|
||||||
|
|
||||||
let content = itemViewController.takeContent()
|
let content = itemViewController.takeContent()
|
||||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||||
content.view.layer.masksToBounds = true
|
content.view.transform = .identity
|
||||||
container.addSubview(content.view)
|
|
||||||
|
|
||||||
content.view.frame = destFrameInContainer
|
|
||||||
content.view.layer.opacity = 1
|
content.view.layer.opacity = 1
|
||||||
|
content.view.frame = contentContainer.bounds
|
||||||
|
contentContainer.addSubview(content.view)
|
||||||
|
|
||||||
container.layoutIfNeeded()
|
container.layoutIfNeeded()
|
||||||
|
|
||||||
|
// Hide overlaid controls immediately, to prevent the Live Text button's position
|
||||||
|
// getting caught up in the rest of the animation.
|
||||||
|
UIView.animate(withDuration: 0.1) {
|
||||||
|
content.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||||
|
}
|
||||||
|
|
||||||
let duration = self.transitionDuration(using: transitionContext)
|
let duration = self.transitionDuration(using: transitionContext)
|
||||||
var initialVelocity: CGVector
|
var initialVelocity: CGVector
|
||||||
if let interactiveVelocity,
|
if let interactiveVelocity,
|
||||||
let interactiveTranslation,
|
let interactiveTranslation,
|
||||||
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the springs initial undershoot
|
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the spring's initial undershoot
|
||||||
sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100,
|
sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100,
|
||||||
sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 {
|
sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 {
|
||||||
let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX
|
let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX
|
||||||
|
@ -102,14 +128,34 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
||||||
|
|
||||||
if appliedSourceToDestTransform {
|
if appliedSourceToDestTransform {
|
||||||
self.sourceView.transform = origSourceTransform
|
self.sourceView.transform = origSourceTransform
|
||||||
|
sourceSnapshot?.transform = origSourceTransform
|
||||||
}
|
}
|
||||||
content.view.frame = sourceFrameInContainer
|
|
||||||
content.view.layer.opacity = 0
|
contentContainer.frame = sourceFrameInContainer
|
||||||
|
// Using sourceSizeWithDestAspectRatioCenteredInContentContainer does not seem to be necessary here.
|
||||||
|
// I guess autoresizing takes care of it?
|
||||||
|
|
||||||
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delay fading out the content because if it's still big while it's semi-transparent,
|
||||||
|
// seeing the stuff behind it looks odd.
|
||||||
|
animator.addAnimations({
|
||||||
|
content.view.layer.opacity = 0
|
||||||
|
}, delayFactor: 0.35)
|
||||||
|
|
||||||
|
if let sourceSnapshot {
|
||||||
|
animator.addAnimations({
|
||||||
|
self.sourceView.layer.opacity = 1
|
||||||
|
sourceSnapshot.layer.opacity = 0
|
||||||
|
}, delayFactor: 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
animator.addCompletion { _ in
|
animator.addCompletion { _ in
|
||||||
|
sourceSnapshot?.removeFromSuperview()
|
||||||
|
|
||||||
|
// Having dismissed, we don't need to undo any of the changes to the content VC.
|
||||||
|
|
||||||
transitionContext.completeTransition(true)
|
transitionContext.completeTransition(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,8 @@ class GalleryDismissInteraction: NSObject {
|
||||||
private(set) var dismissVelocity: CGPoint?
|
private(set) var dismissVelocity: CGPoint?
|
||||||
private(set) var dismissTranslation: CGPoint?
|
private(set) var dismissTranslation: CGPoint?
|
||||||
|
|
||||||
|
private var cancelAnimator: UIViewPropertyAnimator?
|
||||||
|
|
||||||
init(viewController: GalleryViewController) {
|
init(viewController: GalleryViewController) {
|
||||||
self.viewController = viewController
|
self.viewController = viewController
|
||||||
super.init()
|
super.init()
|
||||||
|
@ -38,6 +40,8 @@ class GalleryDismissInteraction: NSObject {
|
||||||
content = viewController.currentItemViewController.takeContent()
|
content = viewController.currentItemViewController.takeContent()
|
||||||
content!.view.translatesAutoresizingMaskIntoConstraints = true
|
content!.view.translatesAutoresizingMaskIntoConstraints = true
|
||||||
content!.view.frame = origContentFrameInGallery!
|
content!.view.frame = origContentFrameInGallery!
|
||||||
|
// Make sure the context remains behind the controls
|
||||||
|
content!.view.layer.zPosition = -1000
|
||||||
viewController.view.addSubview(content!.view)
|
viewController.view.addSubview(content!.view)
|
||||||
|
|
||||||
origControlsVisible = viewController.currentItemViewController.controlsVisible
|
origControlsVisible = viewController.currentItemViewController.controlsVisible
|
||||||
|
@ -53,12 +57,42 @@ class GalleryDismissInteraction: NSObject {
|
||||||
let translation = recognizer.translation(in: viewController.view)
|
let translation = recognizer.translation(in: viewController.view)
|
||||||
let velocity = recognizer.velocity(in: viewController.view)
|
let velocity = recognizer.velocity(in: viewController.view)
|
||||||
|
|
||||||
dismissVelocity = velocity
|
let translationMagnitude = sqrt(translation.x.magnitudeSquared + translation.y.magnitudeSquared)
|
||||||
dismissTranslation = translation
|
let velocityMagnitude = sqrt(velocity.x.magnitudeSquared + velocity.y.magnitudeSquared)
|
||||||
viewController.dismiss(animated: true)
|
|
||||||
|
|
||||||
// don't unset this until after dismiss is called, so that the dismiss animation controller can read it
|
if translationMagnitude < 150 && velocityMagnitude < 500 {
|
||||||
isActive = false
|
isActive = false
|
||||||
|
|
||||||
|
cancelAnimator?.stopAnimation(true)
|
||||||
|
|
||||||
|
let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: .zero)
|
||||||
|
cancelAnimator = UIViewPropertyAnimator(duration: 0.2, timingParameters: spring)
|
||||||
|
cancelAnimator!.addAnimations {
|
||||||
|
self.content!.view.frame = self.origContentFrameInGallery!
|
||||||
|
self.viewController.currentItemViewController.setControlsVisible(self.origControlsVisible!, animated: false, dueToUserInteraction: false)
|
||||||
|
}
|
||||||
|
cancelAnimator!.addCompletion { _ in
|
||||||
|
guard !self.isActive else {
|
||||||
|
// bail in case the animation finishing raced with the user's interaction
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.content!.view.layer.zPosition = 0
|
||||||
|
self.content!.view.removeFromSuperview()
|
||||||
|
self.viewController.currentItemViewController.addContent()
|
||||||
|
self.content = nil
|
||||||
|
self.origContentFrameInGallery = nil
|
||||||
|
self.origControlsVisible = nil
|
||||||
|
}
|
||||||
|
cancelAnimator!.startAnimation()
|
||||||
|
|
||||||
|
} else {
|
||||||
|
dismissVelocity = velocity
|
||||||
|
dismissTranslation = translation
|
||||||
|
viewController.dismiss(animated: true)
|
||||||
|
|
||||||
|
// don't unset this until after dismiss is called, so that the dismiss animation controller can read it
|
||||||
|
isActive = false
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|
|
@ -79,6 +79,10 @@ class GalleryItemViewController: UIViewController {
|
||||||
scrollView = UIScrollView()
|
scrollView = UIScrollView()
|
||||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
scrollView.delegate = self
|
scrollView.delegate = self
|
||||||
|
// We calculate zoom/position ignoring the safe area, so content insets need to not incorporate it either.
|
||||||
|
// Otherwise, content that fills the screen (extending into the safe area) may still end up scrollable
|
||||||
|
// (this is readily observable with tall images on a landscape iPad).
|
||||||
|
scrollView.contentInsetAdjustmentBehavior = .never
|
||||||
|
|
||||||
view.addSubview(scrollView)
|
view.addSubview(scrollView)
|
||||||
|
|
||||||
|
|
|
@ -25,11 +25,31 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
||||||
|
|
||||||
let itemViewController = to.currentItemViewController
|
let itemViewController = to.currentItemViewController
|
||||||
|
|
||||||
if !itemViewController.content.canAnimateFromSourceView || UIAccessibility.prefersCrossFadeTransitions {
|
if itemViewController.content.presentationAnimation == .fade || UIAccessibility.prefersCrossFadeTransitions {
|
||||||
animateCrossFadeTransition(using: transitionContext)
|
animateCrossFadeTransition(using: transitionContext)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to effectively "fade out" anything that's on top of the source view.
|
||||||
|
// The 0.1 duration makes this happen faster than the rest of the animation,
|
||||||
|
// and so less noticeable.
|
||||||
|
let sourceSnapshot: UIView? = if itemViewController.content.presentationAnimation == .fromSourceViewWithoutSnapshot {
|
||||||
|
nil
|
||||||
|
} else {
|
||||||
|
sourceView.snapshotView(afterScreenUpdates: false)
|
||||||
|
}
|
||||||
|
if let sourceSnapshot {
|
||||||
|
let snapshotContainer = sourceView.ancestorForInsertingSnapshot
|
||||||
|
snapshotContainer.addSubview(sourceSnapshot)
|
||||||
|
let sourceFrameInShapshotContainer = snapshotContainer.convert(sourceView.bounds, from: sourceView)
|
||||||
|
sourceSnapshot.frame = sourceFrameInShapshotContainer
|
||||||
|
sourceSnapshot.transform = sourceView.transform
|
||||||
|
sourceSnapshot.layer.opacity = 0
|
||||||
|
UIView.animate(withDuration: 0.1) {
|
||||||
|
sourceSnapshot.layer.opacity = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let container = transitionContext.containerView
|
let container = transitionContext.containerView
|
||||||
to.view.frame = container.bounds
|
to.view.frame = container.bounds
|
||||||
container.addSubview(to.view)
|
container.addSubview(to.view)
|
||||||
|
@ -56,21 +76,70 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
||||||
sourceToDestTransform = nil
|
sourceToDestTransform = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grab these before taking the content out and changing the transform.
|
||||||
|
let origContentTransform = itemViewController.content.view.transform
|
||||||
|
let origContentFrame = itemViewController.content.view.frame
|
||||||
|
|
||||||
|
// The content container provides the clipping for the content view,
|
||||||
|
// which, in case the source/dest aspect ratios don't match, makes
|
||||||
|
// it look like the content is expanding out from the source rect.
|
||||||
|
let contentContainer = UIView()
|
||||||
|
contentContainer.layer.masksToBounds = true
|
||||||
|
container.insertSubview(contentContainer, belowSubview: to.view)
|
||||||
let content = itemViewController.takeContent()
|
let content = itemViewController.takeContent()
|
||||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||||
container.insertSubview(content.view, belowSubview: to.view)
|
content.view.transform = .identity
|
||||||
|
// The fade-in makes the aspect ratio handling look a little bit worse,
|
||||||
|
// but papers over the z-index change and potential corner radius change.
|
||||||
|
content.view.layer.opacity = 0
|
||||||
|
contentContainer.addSubview(content.view)
|
||||||
|
|
||||||
// Use a separate dimming view from to.view, so that the gallery controls can be in front of the moving content.
|
// Use a separate dimming view from to.view, so that the gallery controls can be in front of the moving content.
|
||||||
let dimmingView = UIView()
|
let dimmingView = UIView()
|
||||||
dimmingView.backgroundColor = .black
|
dimmingView.backgroundColor = .black
|
||||||
dimmingView.frame = container.bounds
|
dimmingView.frame = container.bounds
|
||||||
dimmingView.layer.opacity = 0
|
dimmingView.layer.opacity = 0
|
||||||
container.insertSubview(dimmingView, belowSubview: content.view)
|
container.insertSubview(dimmingView, belowSubview: contentContainer)
|
||||||
|
|
||||||
to.view.backgroundColor = nil
|
to.view.backgroundColor = nil
|
||||||
to.view.layer.opacity = 0
|
to.view.layer.opacity = 0
|
||||||
content.view.frame = sourceFrameInContainer
|
|
||||||
content.view.layer.opacity = 0
|
contentContainer.frame = sourceFrameInContainer
|
||||||
|
|
||||||
|
let sourceAspectRatio: CGFloat = if sourceFrameInContainer.height > 0 {
|
||||||
|
sourceFrameInContainer.width / sourceFrameInContainer.height
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
let destAspectRatio: CGFloat = if destFrameInContainer.height > 0 {
|
||||||
|
destFrameInContainer.width / destFrameInContainer.height
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
let sourceSizeWithDestAspectRatioCenteredInContentContainer: CGRect
|
||||||
|
if 0.001 < abs(sourceAspectRatio - destAspectRatio) {
|
||||||
|
// asepct ratios are effectively equal
|
||||||
|
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(origin: .zero, size: sourceFrameInContainer.size)
|
||||||
|
} else if sourceAspectRatio < destAspectRatio {
|
||||||
|
// source aspect ratio is narrow/taller than dest
|
||||||
|
let width = sourceFrameInContainer.height * destAspectRatio
|
||||||
|
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(
|
||||||
|
x: -(width - sourceFrameInContainer.width) / 2,
|
||||||
|
y: 0,
|
||||||
|
width: width,
|
||||||
|
height: sourceFrameInContainer.height
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// source aspect ratio is wider/shorter than dest
|
||||||
|
let height = sourceFrameInContainer.width / destAspectRatio
|
||||||
|
sourceSizeWithDestAspectRatioCenteredInContentContainer = CGRect(
|
||||||
|
x: 0,
|
||||||
|
y: -(height - sourceFrameInContainer.height) / 2,
|
||||||
|
width: sourceFrameInContainer.width,
|
||||||
|
height: height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
content.view.frame = sourceSizeWithDestAspectRatioCenteredInContentContainer
|
||||||
|
|
||||||
container.layoutIfNeeded()
|
container.layoutIfNeeded()
|
||||||
|
|
||||||
|
@ -78,8 +147,14 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
||||||
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
itemViewController.setControlsVisible(false, animated: false, dueToUserInteraction: false)
|
||||||
|
|
||||||
let duration = self.transitionDuration(using: transitionContext)
|
let duration = self.transitionDuration(using: transitionContext)
|
||||||
// rougly equivalent to duration: 0.35, bounce: 0.3
|
// less bounce on bigger screens
|
||||||
let spring = UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero)
|
let spring = if UIDevice.current.userInterfaceIdiom == .pad {
|
||||||
|
// roughly equivalent to duration: 0.35, bounce: 0.2
|
||||||
|
UISpringTimingParameters(mass: 1, stiffness: 322, damping: 28, initialVelocity: .zero)
|
||||||
|
} else {
|
||||||
|
// roughly equivalent to duration: 0.35, bounce: 0.3
|
||||||
|
UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero)
|
||||||
|
}
|
||||||
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
|
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
|
||||||
|
|
||||||
animator.addAnimations {
|
animator.addAnimations {
|
||||||
|
@ -87,24 +162,34 @@ class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimated
|
||||||
|
|
||||||
to.view.layer.opacity = 1
|
to.view.layer.opacity = 1
|
||||||
|
|
||||||
content.view.frame = destFrameInContainer
|
contentContainer.frame = destFrameInContainer
|
||||||
|
content.view.frame = contentContainer.bounds
|
||||||
content.view.layer.opacity = 1
|
content.view.layer.opacity = 1
|
||||||
|
|
||||||
itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
|
itemViewController.setControlsVisible(true, animated: false, dueToUserInteraction: false)
|
||||||
|
|
||||||
if let sourceToDestTransform {
|
if let sourceToDestTransform {
|
||||||
|
sourceSnapshot?.transform = sourceToDestTransform
|
||||||
self.sourceView.transform = sourceToDestTransform
|
self.sourceView.transform = sourceToDestTransform
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
animator.addCompletion { _ in
|
animator.addCompletion { _ in
|
||||||
|
sourceSnapshot?.removeFromSuperview()
|
||||||
|
self.sourceView.layer.opacity = 1
|
||||||
|
if sourceToDestTransform != nil {
|
||||||
|
self.sourceView.transform = origSourceTransform
|
||||||
|
}
|
||||||
|
|
||||||
|
contentContainer.removeFromSuperview()
|
||||||
dimmingView.removeFromSuperview()
|
dimmingView.removeFromSuperview()
|
||||||
|
|
||||||
to.view.backgroundColor = .black
|
to.view.backgroundColor = .black
|
||||||
|
|
||||||
if sourceToDestTransform != nil {
|
// Reset the properties we changed before re-adding the content to the scroll view.
|
||||||
self.sourceView.transform = origSourceTransform
|
// (I would expect UIScrollView to effectively do this itself, but w/e.)
|
||||||
}
|
content.view.transform = origContentTransform
|
||||||
|
content.view.frame = origContentFrame
|
||||||
|
|
||||||
itemViewController.addContent()
|
itemViewController.addContent()
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
//
|
||||||
|
// UIView+Utilities.swift
|
||||||
|
// GalleryVC
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/24/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIView {
|
||||||
|
|
||||||
|
var ancestorForInsertingSnapshot: UIView {
|
||||||
|
var view = self
|
||||||
|
while let superview = view.superview {
|
||||||
|
if superview.layer.masksToBounds {
|
||||||
|
return superview
|
||||||
|
} else if superview is UIScrollView {
|
||||||
|
return self
|
||||||
|
} else {
|
||||||
|
view = superview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -22,7 +22,12 @@ public struct Emoji: Codable, Sendable {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
self.shortcode = try container.decode(String.self, forKey: .shortcode)
|
self.shortcode = try container.decode(String.self, forKey: .shortcode)
|
||||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
do {
|
||||||
|
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||||
|
} catch {
|
||||||
|
let s = try? container.decode(String.self, forKey: .url)
|
||||||
|
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'", underlyingError: error))
|
||||||
|
}
|
||||||
self.staticURL = try container.decode(WebURL.self, forKey: .staticURL)
|
self.staticURL = try container.decode(WebURL.self, forKey: .staticURL)
|
||||||
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
|
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
|
||||||
self.category = try container.decodeIfPresent(String.self, forKey: .category)
|
self.category = try container.decodeIfPresent(String.self, forKey: .category)
|
||||||
|
|
|
@ -375,13 +375,14 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer, @unchecked Se
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
|
func addAll(notifications: [Pachyderm.Notification], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) {
|
||||||
backgroundContext.perform {
|
let context = context ?? backgroundContext
|
||||||
|
context.perform {
|
||||||
let statuses = notifications.compactMap { $0.status }
|
let statuses = notifications.compactMap { $0.status }
|
||||||
let accounts = notifications.map { $0.account }
|
let accounts = notifications.map { $0.account }
|
||||||
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
|
statuses.forEach { self.upsert(status: $0, context: context) }
|
||||||
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
|
accounts.forEach { self.upsert(account: $0, in: context) }
|
||||||
self.save(context: self.backgroundContext)
|
self.save(context: context)
|
||||||
completion?()
|
completion?()
|
||||||
statuses.forEach { self.statusSubject.send($0.id) }
|
statuses.forEach { self.statusSubject.send($0.id) }
|
||||||
accounts.forEach { self.accountSubject.send($0.id) }
|
accounts.forEach { self.accountSubject.send($0.id) }
|
||||||
|
|
|
@ -69,4 +69,8 @@ class GifvGalleryContentViewController: UIViewController, GalleryContentViewCont
|
||||||
[VideoActivityItemSource(asset: controller.item.asset, url: url)]
|
[VideoActivityItemSource(asset: controller.item.asset, url: url)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var presentationAnimation: GalleryContentPresentationAnimation {
|
||||||
|
.fromSourceViewWithoutSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,13 @@ final class NewMainTabBarViewController: BaseMainTabBarViewController {
|
||||||
@Box fileprivate var myProfileCell: UIView?
|
@Box fileprivate var myProfileCell: UIView?
|
||||||
private var sidebarTapRecognizer: UITapGestureRecognizer?
|
private var sidebarTapRecognizer: UITapGestureRecognizer?
|
||||||
|
|
||||||
|
private lazy var fastAccountSwitcherIndicator: UIView = {
|
||||||
|
let indicator = FastAccountSwitcherIndicatorView()
|
||||||
|
// need to explicitly set the frame to get it vertically centered
|
||||||
|
indicator.frame = CGRect(origin: .zero, size: indicator.intrinsicContentSize)
|
||||||
|
return indicator
|
||||||
|
}()
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
@ -513,13 +520,6 @@ extension NewMainTabBarViewController: UITabBarControllerDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var fastAccountSwitcherIndicator: UIView = {
|
|
||||||
let indicator = FastAccountSwitcherIndicatorView()
|
|
||||||
// need to explicitly set the frame to get it vertically centered
|
|
||||||
indicator.frame = CGRect(origin: .zero, size: indicator.intrinsicContentSize)
|
|
||||||
return indicator
|
|
||||||
}()
|
|
||||||
|
|
||||||
@available(iOS 18.0, *)
|
@available(iOS 18.0, *)
|
||||||
extension NewMainTabBarViewController: UITabBarController.Sidebar.Delegate {
|
extension NewMainTabBarViewController: UITabBarController.Sidebar.Delegate {
|
||||||
func tabBarController(_ tabBarController: UITabBarController, sidebarVisibilityWillChange sidebar: UITabBarController.Sidebar, animator: any UITabBarController.Sidebar.Animating) {
|
func tabBarController(_ tabBarController: UITabBarController, sidebarVisibilityWillChange sidebar: UITabBarController.Sidebar, animator: any UITabBarController.Sidebar.Animating) {
|
||||||
|
|
|
@ -48,7 +48,9 @@ class NotificationLoadingViewController: UIViewController {
|
||||||
do {
|
do {
|
||||||
let (notification, _) = try await mastodonController.run(request)
|
let (notification, _) = try await mastodonController.run(request)
|
||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
mastodonController.persistentContainer.addAll(notifications: [notification]) {
|
let container = mastodonController.persistentContainer
|
||||||
|
let context = container.viewContext
|
||||||
|
container.addAll(notifications: [notification], in: context) {
|
||||||
continuation.resume()
|
continuation.resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,11 +77,25 @@ class InstanceTimelineViewController: TimelineViewController {
|
||||||
cell.updateUI(statusID: id, state: state, filterResult: filterResult, precomputedContent: precomputedContent)
|
cell.updateUI(statusID: id, state: state, filterResult: filterResult, precomputedContent: precomputedContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
|
||||||
|
guard browsingEnabled else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return super.collectionView(collectionView, shouldSelectItemAt: indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
guard browsingEnabled else { return }
|
guard browsingEnabled else { return }
|
||||||
super.collectionView(collectionView, didSelectItemAt: indexPath)
|
super.collectionView(collectionView, didSelectItemAt: indexPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||||
|
guard browsingEnabled else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return super.collectionView(collectionView, contextMenuConfigurationForItemAt: indexPath, point: point)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Timeline
|
// MARK: Timeline
|
||||||
|
|
||||||
override func handleLoadAllError(_ error: Swift.Error) async {
|
override func handleLoadAllError(_ error: Swift.Error) async {
|
||||||
|
|
|
@ -295,7 +295,11 @@ class AttachmentsContainerView: UIView {
|
||||||
accessibilityElements.append(moreView)
|
accessibilityElements.append(moreView)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.aspectRatio = aspectRatio
|
self.aspectRatio = if aspectRatio.isNaN || aspectRatio.isInfinite {
|
||||||
|
16/9
|
||||||
|
} else {
|
||||||
|
aspectRatio
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.isHidden = true
|
self.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,9 +60,9 @@ class GifvController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updatePresentationSizeObservation() {
|
private func updatePresentationSizeObservation() {
|
||||||
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [unowned self] item, _ in
|
presentationSizeObservation = item.observe(\.presentationSize, changeHandler: { [weak self] item, _ in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.presentationSizeSubject.send(item.presentationSize)
|
self?.presentationSizeSubject.send(item.presentationSize)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,8 @@
|
||||||
// Configuration settings file format documentation can be found at:
|
// Configuration settings file format documentation can be found at:
|
||||||
// https://help.apple.com/xcode/#/dev745c5c974
|
// https://help.apple.com/xcode/#/dev745c5c974
|
||||||
|
|
||||||
MARKETING_VERSION = 2024.4
|
MARKETING_VERSION = 2024.5
|
||||||
CURRENT_PROJECT_VERSION = 136
|
CURRENT_PROJECT_VERSION = 137
|
||||||
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
|
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
|
||||||
|
|
||||||
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
|
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
|
||||||
|
|
Loading…
Reference in New Issue