@@ -9,47 +9,84 @@ import BaseToolbox
9
9
import ScreenCorners
10
10
import UIKit
11
11
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.
12
14
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 ?
14
17
}
15
18
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
19
22
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
24
49
open lazy var dismissGestureRecognizer = UIPanGestureRecognizer ( target: self , action: #selector( handlePan ( gr: ) ) ) . then {
25
50
$0. delegate = self
26
51
if #available( iOS 13 . 4 , * ) {
27
52
$0. allowedScrollTypesMask = . all
28
53
}
29
54
}
30
55
56
+ private let foregroundContainerView = MatchTransitionContainerView ( )
57
+ private var isMatched = false
58
+ private( set) open var isTransitioningVertically = false
59
+
31
60
open override func animate( ) {
32
61
guard let back = backgroundView, let front = foregroundView, let container = transitionContainer else {
33
62
fatalError ( )
34
63
}
64
+
35
65
let matchedDestinationView = foregroundViewController? . findObjectMatchType ( MatchTransitionDelegate . self) ?
36
66
. matchedViewFor ( transition: self , otherViewController: backgroundViewController!)
37
67
let matchedSourceView = backgroundViewController? . findObjectMatchType ( MatchTransitionDelegate . self) ?
38
68
. matchedViewFor ( transition: self , otherViewController: foregroundViewController!)
39
69
70
+ isMatched = matchedSourceView != nil
71
+
72
+ if isPresenting {
73
+ if options. automaticallyAddDismissGestureRecognizer {
74
+ front. addGestureRecognizer ( dismissGestureRecognizer)
75
+ }
76
+ }
77
+
40
78
let isFullScreen = container. window? . convert ( container. bounds, from: container) == container. window? . bounds
41
79
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
45
81
foregroundContainerView. cornerRadius = finalCornerRadius
46
- foregroundContainerView. clipsToBounds = true
47
82
foregroundContainerView. frame = container. bounds
48
83
foregroundContainerView. backgroundColor = front. backgroundColor
84
+ foregroundContainerView. shadowColor = . black
49
85
container. addSubview ( foregroundContainerView)
50
- foregroundContainerView. addSubview ( front)
86
+ foregroundContainerView. contentView. addSubview ( front)
87
+
51
88
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 )
53
90
let dismissedFrame =
54
91
matchedSourceView. map {
55
92
container. convert ( $0. bounds, from: $0)
@@ -63,13 +100,21 @@ open class MatchModalTransition: Transition {
63
100
let sourceViewPlaceholder = UIView ( )
64
101
if let matchedSourceView = matchedSourceView {
65
102
matchedSourceView. superview? . insertSubview ( sourceViewPlaceholder, aboveSubview: matchedSourceView)
66
- foregroundContainerView. addSubview ( matchedSourceView)
103
+ foregroundContainerView. contentView . addSubview ( matchedSourceView)
67
104
}
68
- isMatched = matchedSourceView != nil
69
105
70
106
addDismissStateBlock {
71
107
foregroundContainerView. cornerRadius = matchedSourceView? . cornerRadius ?? 0
72
108
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
73
118
if let matchedSourceView = matchedSourceView {
74
119
let scaledSize = presentedFrame. size. size ( fill: dismissedFrame. size)
75
120
let scale = scaledSize. width / container. bounds. width
@@ -91,6 +136,15 @@ open class MatchModalTransition: Transition {
91
136
addPresentStateBlock {
92
137
foregroundContainerView. cornerRadius = finalCornerRadius
93
138
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
94
148
front. transform = . identity
95
149
matchedSourceView? . frameWithoutTransform = presentedFrame
96
150
matchedSourceView? . alpha = 0
@@ -117,55 +171,57 @@ open class MatchModalTransition: Transition {
117
171
}
118
172
}
119
173
174
+ open override func animationEnded( _ transitionCompleted: Bool ) {
175
+ isMatched = false
176
+ isTransitioningVertically = false
177
+ super. animationEnded ( transitionCompleted)
178
+ }
179
+
120
180
func pauseForegroundView( ) {
121
181
let position = foregroundContainerView. layer. presentation ( ) ? . position ?? foregroundContainerView. layer. position
122
182
self . pause ( view: foregroundContainerView, animationForKey: " position " )
123
183
foregroundContainerView. layer. position = position
124
184
}
125
185
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
136
187
@objc func handlePan( gr: UIPanGestureRecognizer ) {
137
188
guard let view = gr. view else { return }
138
189
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
+ }
141
199
}
142
200
switch gr. state {
143
201
case . began:
202
+ options. onDragStart ? ( self )
144
203
if !isTransitioning {
145
204
beginInteractiveTransition ( )
146
205
view. dismiss ( )
147
206
} else {
148
207
beginInteractiveTransition ( )
149
208
pause ( view: foregroundContainerView, animationForKey: " position " )
150
209
}
151
- accumulatedProgress = 0
210
+ totalTranslation = . zero
152
211
case . changed:
153
212
let translation = gr. translation ( in: nil )
154
213
gr. setTranslation ( . zero, in: nil )
214
+ totalTranslation += translation
215
+ let progress = progressFrom ( offset: translation)
155
216
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
164
218
}
219
+ fractionCompleted = ( fractionCompleted + progress) . clamp ( 0 , 1 )
165
220
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 {
169
225
foregroundContainerView. isUserInteractionEnabled = false
170
226
backgroundView? . overlayView? . isUserInteractionEnabled = false
171
227
}
@@ -174,13 +230,13 @@ open class MatchModalTransition: Transition {
174
230
}
175
231
}
176
232
177
- extension MatchModalTransition : UIGestureRecognizerDelegate {
233
+ extension MatchTransition : UIGestureRecognizerDelegate {
178
234
open func gestureRecognizerShouldBegin( _ gestureRecognizer: UIGestureRecognizer ) -> Bool {
179
235
guard gestureRecognizer. view? . canBeDismissed == true else { return false }
180
236
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
184
240
// only allow right and down swipe
185
241
return horizontal || vertical
186
242
}
@@ -193,3 +249,37 @@ extension MatchModalTransition: UIGestureRecognizerDelegate {
193
249
return false
194
250
}
195
251
}
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