Skip to content

Commit 5e18f72

Browse files
committed
more polish and cleanup to MatchTransition
1 parent eb79467 commit 5e18f72

File tree

2 files changed

+141
-48
lines changed

2 files changed

+141
-48
lines changed

Examples/Hero2Example/Examples/InstagramViewController.swift

+7-4
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class InstagramViewController: ComponentViewController {
5656
}
5757

5858
extension InstagramViewController: MatchTransitionDelegate {
59-
func matchedViewFor(transition: MatchModalTransition, otherViewController: UIViewController) -> UIView? {
59+
func matchedViewFor(transition: MatchTransition, otherViewController: UIViewController) -> UIView? {
6060
guard let otherViewController = otherViewController as? InstagramDetailViewController else { return nil }
6161
return view.flattendSubviews.first {
6262
$0.heroID == otherViewController.image.id
@@ -75,7 +75,9 @@ class InstagramDetailViewController: ComponentViewController {
7575
override var component: any Component {
7676
VStack {
7777
HStack(alignItems: .center) {
78-
Image(systemName: "chevron.left").tintColor(.label)
78+
Image(systemName: "chevron.left").tintColor(.label).tappableView { [weak self] in
79+
self?.dismiss(animated: true)
80+
}
7981
}
8082
.size(width: .fill, height: 44)
8183
.overlay(
@@ -101,11 +103,12 @@ class InstagramDetailViewController: ComponentViewController {
101103
.scrollView().flex()
102104
}
103105
}
104-
let matchTransition = MatchModalTransition()
106+
let matchTransition = MatchTransition()
105107

106108
init() {
107109
super.init(nibName: nil, bundle: nil)
108110
transition = matchTransition
111+
matchTransition.timingParameters = UISpringTimingParameters(dampingRatio: 0.9)
109112
matchTransition.isUserInteractionEnabled = true
110113
}
111114

@@ -123,7 +126,7 @@ class InstagramDetailViewController: ComponentViewController {
123126
}
124127

125128
extension InstagramDetailViewController: MatchTransitionDelegate {
126-
func matchedViewFor(transition: MatchModalTransition, otherViewController: UIViewController) -> UIView? {
129+
func matchedViewFor(transition: MatchTransition, otherViewController: UIViewController) -> UIView? {
127130
return imageView
128131
}
129132
}

Sources/Hero2/BuiltinTransitions/MatchModalTransition.swift Sources/Hero2/BuiltinTransitions/MatchTransition.swift

+134-44
Original file line numberDiff line numberDiff line change
@@ -9,47 +9,84 @@ import BaseToolbox
99
import ScreenCorners
1010
import UIKit
1111

12+
/// Foreground ViewController and Background ViewController can implement this protocol to provide
13+
/// a matching view for the transition to animate. This can also be implemented on the View level.
1214
public protocol MatchTransitionDelegate {
13-
func matchedViewFor(transition: MatchModalTransition, otherViewController: UIViewController) -> UIView?
15+
/// Provide the matched view from the current object's own view hierarchy for the match transition
16+
func matchedViewFor(transition: MatchTransition, otherViewController: UIViewController) -> UIView?
1417
}
1518

16-
open class MatchModalTransition: Transition {
17-
let foregroundContainerView = UIView()
18-
var isMatched = false
19+
public struct MatchTransitionOptions {
20+
/// Allow the transition to dismiss vertically via its `dismissGestureRecognizer`
21+
public var canDismissVertically = true
1922

20-
public var transitionVertically = false
21-
open var canDismissVertically = true
22-
open var canDismissHorizontally = true
23-
open var automaticallyAddDismissGestureRecognizer: Bool = true
23+
/// Allow the transition to dismiss horizontally via its `dismissGestureRecognizer`
24+
public var canDismissHorizontally = true
25+
26+
/// If `true`, the `dismissGestureRecognizer` will be automatically added to the foreground view during presentation
27+
public var automaticallyAddDismissGestureRecognizer: Bool = true
28+
29+
/// How much the foreground container moves when user drag across screen. This can be any value above or equal to 0.
30+
/// Default is 0.5, which means when user drag across the screen from left to right, the container move 50% of the screen.
31+
public var dragTranslationFactor: CGPoint = CGPoint(x: 0.5, y: 0.5)
32+
33+
public var onDragStart: ((MatchTransition) -> ())?
34+
}
35+
36+
/// A Transition that matches two items and transitions between them.
37+
///
38+
/// The foreground view will be masked to the item and expand as the transition
39+
/// progress. This transition is interruptible if `isUserInteractionEnabled` is set to true.
40+
///
41+
open class MatchTransition: Transition {
42+
/// Global transition options
43+
public static var defaultOptions = MatchTransitionOptions()
44+
45+
/// Transition options
46+
open var options = MatchTransition.defaultOptions
47+
48+
/// Dismiss gesture recognizer, add this to your view to support drag to dismiss
2449
open lazy var dismissGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(gr:))).then {
2550
$0.delegate = self
2651
if #available(iOS 13.4, *) {
2752
$0.allowedScrollTypesMask = .all
2853
}
2954
}
3055

56+
private let foregroundContainerView = MatchTransitionContainerView()
57+
private var isMatched = false
58+
private(set) open var isTransitioningVertically = false
59+
3160
open override func animate() {
3261
guard let back = backgroundView, let front = foregroundView, let container = transitionContainer else {
3362
fatalError()
3463
}
64+
3565
let matchedDestinationView = foregroundViewController?.findObjectMatchType(MatchTransitionDelegate.self)?
3666
.matchedViewFor(transition: self, otherViewController: backgroundViewController!)
3767
let matchedSourceView = backgroundViewController?.findObjectMatchType(MatchTransitionDelegate.self)?
3868
.matchedViewFor(transition: self, otherViewController: foregroundViewController!)
3969

70+
isMatched = matchedSourceView != nil
71+
72+
if isPresenting {
73+
if options.automaticallyAddDismissGestureRecognizer {
74+
front.addGestureRecognizer(dismissGestureRecognizer)
75+
}
76+
}
77+
4078
let isFullScreen = container.window?.convert(container.bounds, from: container) == container.window?.bounds
4179
let foregroundContainerView = self.foregroundContainerView
42-
let finalCornerRadius: CGFloat = isFullScreen ? UIScreen.main.displayCornerRadius : foregroundContainerView.cornerRadius
43-
foregroundContainerView.autoresizingMask = []
44-
foregroundContainerView.autoresizesSubviews = false
80+
let finalCornerRadius: CGFloat = isFullScreen ? UIScreen.main.displayCornerRadius : 0
4581
foregroundContainerView.cornerRadius = finalCornerRadius
46-
foregroundContainerView.clipsToBounds = true
4782
foregroundContainerView.frame = container.bounds
4883
foregroundContainerView.backgroundColor = front.backgroundColor
84+
foregroundContainerView.shadowColor = .black
4985
container.addSubview(foregroundContainerView)
50-
foregroundContainerView.addSubview(front)
86+
foregroundContainerView.contentView.addSubview(front)
87+
5188
let defaultDismissedFrame =
52-
transitionVertically ? container.bounds.offsetBy(dx: 0, dy: container.bounds.height) : container.bounds.offsetBy(dx: container.bounds.width, dy: 0)
89+
isTransitioningVertically ? container.bounds.offsetBy(dx: 0, dy: container.bounds.height) : container.bounds.offsetBy(dx: container.bounds.width, dy: 0)
5390
let dismissedFrame =
5491
matchedSourceView.map {
5592
container.convert($0.bounds, from: $0)
@@ -63,13 +100,21 @@ open class MatchModalTransition: Transition {
63100
let sourceViewPlaceholder = UIView()
64101
if let matchedSourceView = matchedSourceView {
65102
matchedSourceView.superview?.insertSubview(sourceViewPlaceholder, aboveSubview: matchedSourceView)
66-
foregroundContainerView.addSubview(matchedSourceView)
103+
foregroundContainerView.contentView.addSubview(matchedSourceView)
67104
}
68-
isMatched = matchedSourceView != nil
69105

70106
addDismissStateBlock {
71107
foregroundContainerView.cornerRadius = matchedSourceView?.cornerRadius ?? 0
72108
foregroundContainerView.frameWithoutTransform = dismissedFrame
109+
110+
// UIKit Bug: If we add a shadowPath animation, when the UIViewPropertyAnimator pauses,
111+
// the animation will jump directly to the end. fractionCompleted value seem to be messed up.
112+
// commenting out this line until it gets fixed.
113+
//
114+
// foregroundContainerView.recalculateShadowPath()
115+
116+
foregroundContainerView.shadowOpacity = 0.0
117+
foregroundContainerView.shadowRadius = 8
73118
if let matchedSourceView = matchedSourceView {
74119
let scaledSize = presentedFrame.size.size(fill: dismissedFrame.size)
75120
let scale = scaledSize.width / container.bounds.width
@@ -91,6 +136,15 @@ open class MatchModalTransition: Transition {
91136
addPresentStateBlock {
92137
foregroundContainerView.cornerRadius = finalCornerRadius
93138
foregroundContainerView.frameWithoutTransform = container.bounds
139+
140+
// UIKit Bug: If we add a shadowPath animation, when the UIViewPropertyAnimator pauses,
141+
// the animation will jump directly to the end. fractionCompleted value seem to be messed up.
142+
// commenting out this line until it gets fixed.
143+
//
144+
// foregroundContainerView.recalculateShadowPath()
145+
146+
foregroundContainerView.shadowOpacity = 0.4
147+
foregroundContainerView.shadowRadius = 32
94148
front.transform = .identity
95149
matchedSourceView?.frameWithoutTransform = presentedFrame
96150
matchedSourceView?.alpha = 0
@@ -117,55 +171,57 @@ open class MatchModalTransition: Transition {
117171
}
118172
}
119173

174+
open override func animationEnded(_ transitionCompleted: Bool) {
175+
isMatched = false
176+
isTransitioningVertically = false
177+
super.animationEnded(transitionCompleted)
178+
}
179+
120180
func pauseForegroundView() {
121181
let position = foregroundContainerView.layer.presentation()?.position ?? foregroundContainerView.layer.position
122182
self.pause(view: foregroundContainerView, animationForKey: "position")
123183
foregroundContainerView.layer.position = position
124184
}
125185

126-
open override func animationEnded(_ transitionCompleted: Bool) {
127-
if isPresenting, transitionCompleted, automaticallyAddDismissGestureRecognizer {
128-
foregroundView?.addGestureRecognizer(dismissGestureRecognizer)
129-
}
130-
isMatched = false
131-
transitionVertically = false
132-
super.animationEnded(transitionCompleted)
133-
}
134-
135-
var accumulatedProgress: CGFloat = 0
186+
var totalTranslation: CGPoint = .zero
136187
@objc func handlePan(gr: UIPanGestureRecognizer) {
137188
guard let view = gr.view else { return }
138189
func progressFrom(offset: CGPoint) -> CGFloat {
139-
let progress = (offset.x + offset.y) / ((view.bounds.height + view.bounds.width) / 4)
140-
return isPresenting ? -progress : progress
190+
guard let container = transitionContainer else { return 0 }
191+
if isMatched {
192+
let maxAxis = max(container.bounds.width, container.bounds.height)
193+
let progress = (offset.x / maxAxis + offset.y / maxAxis) * 1.0
194+
return isPresenting ? -progress : progress
195+
} else {
196+
let progress = isTransitioningVertically ? offset.y / container.bounds.height : offset.x / container.bounds.width
197+
return isPresenting ? -progress : progress
198+
}
141199
}
142200
switch gr.state {
143201
case .began:
202+
options.onDragStart?(self)
144203
if !isTransitioning {
145204
beginInteractiveTransition()
146205
view.dismiss()
147206
} else {
148207
beginInteractiveTransition()
149208
pause(view: foregroundContainerView, animationForKey: "position")
150209
}
151-
accumulatedProgress = 0
210+
totalTranslation = .zero
152211
case .changed:
153212
let translation = gr.translation(in: nil)
154213
gr.setTranslation(.zero, in: nil)
214+
totalTranslation += translation
215+
let progress = progressFrom(offset: translation)
155216
if isMatched {
156-
let progress = progressFrom(offset: translation)
157-
foregroundContainerView.center = foregroundContainerView.center + translation * 0.5
158-
fractionCompleted = (fractionCompleted + progress * 0.1).clamp(0, 1)
159-
accumulatedProgress += progress
160-
} else {
161-
let progress = transitionVertically ? translation.y / view.bounds.height : translation.x / view.bounds.width
162-
fractionCompleted = (fractionCompleted + progress).clamp(0, 1)
163-
accumulatedProgress += progress
217+
foregroundContainerView.center = foregroundContainerView.center + translation * options.dragTranslationFactor
164218
}
219+
fractionCompleted = (fractionCompleted + progress).clamp(0, 1)
165220
default:
166-
let progress = accumulatedProgress + progressFrom(offset: gr.velocity(in: nil)) * 0.3
167-
let shouldFinish = progress > 0.5
168-
if isPresenting != shouldFinish {
221+
let translationPlusVelocity = totalTranslation + gr.velocity(in: nil)
222+
let shouldDismiss = translationPlusVelocity.x + translationPlusVelocity.y > 80
223+
let shouldFinish = isPresenting ? !shouldDismiss : shouldDismiss
224+
if shouldDismiss {
169225
foregroundContainerView.isUserInteractionEnabled = false
170226
backgroundView?.overlayView?.isUserInteractionEnabled = false
171227
}
@@ -174,13 +230,13 @@ open class MatchModalTransition: Transition {
174230
}
175231
}
176232

177-
extension MatchModalTransition: UIGestureRecognizerDelegate {
233+
extension MatchTransition: UIGestureRecognizerDelegate {
178234
open func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
179235
guard gestureRecognizer.view?.canBeDismissed == true else { return false }
180236
let velocity = dismissGestureRecognizer.velocity(in: nil)
181-
let horizontal = canDismissHorizontally && velocity.x > abs(velocity.y)
182-
let vertical = canDismissVertically && velocity.y > abs(velocity.x)
183-
transitionVertically = vertical
237+
let horizontal = options.canDismissHorizontally && velocity.x > abs(velocity.y)
238+
let vertical = options.canDismissVertically && velocity.y > abs(velocity.x)
239+
isTransitioningVertically = vertical
184240
// only allow right and down swipe
185241
return horizontal || vertical
186242
}
@@ -193,3 +249,37 @@ extension MatchModalTransition: UIGestureRecognizerDelegate {
193249
return false
194250
}
195251
}
252+
253+
254+
private class MatchTransitionContainerView: UIView {
255+
let contentView = UIView()
256+
257+
override var cornerRadius: CGFloat {
258+
didSet {
259+
contentView.cornerRadius = cornerRadius
260+
}
261+
}
262+
263+
override init(frame: CGRect) {
264+
super.init(frame: frame)
265+
addSubview(contentView)
266+
cornerCurve = .continuous
267+
contentView.cornerCurve = .continuous
268+
contentView.autoresizingMask = []
269+
contentView.autoresizesSubviews = false
270+
contentView.clipsToBounds = true
271+
}
272+
273+
required init?(coder: NSCoder) {
274+
fatalError("init(coder:) has not been implemented")
275+
}
276+
277+
override func layoutSubviews() {
278+
super.layoutSubviews()
279+
contentView.frame = bounds
280+
}
281+
282+
func recalculateShadowPath() {
283+
shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius)
284+
}
285+
}

0 commit comments

Comments
 (0)