├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Tests
└── GZMatchedTransformEffectTests
│ └── MatchedTransformEffectTests.swift
├── Package.swift
├── Sources
└── GZMatchedTransformEffect
│ ├── SizePreference.swift
│ └── MatchedTransformEffect.swift
├── LICENSE
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Tests/GZMatchedTransformEffectTests/MatchedTransformEffectTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import GZMatchedTransformEffect
3 |
4 | final class MatchedTransformEffectTests: XCTestCase {
5 | func testNothing() throws {
6 | // NOTE: If you know how to create a UITest in SPM, please let me know. Thanks!
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.5
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "GZMatchedTransformEffect",
7 | platforms: [
8 | .iOS(.v14), .macOS(.v11), .watchOS(.v7)
9 | ],
10 | products: [
11 | .library(
12 | name: "GZMatchedTransformEffect",
13 | targets: ["GZMatchedTransformEffect"]),
14 | ],
15 | dependencies: [
16 | ],
17 | targets: [
18 | .target(
19 | name: "GZMatchedTransformEffect",
20 | dependencies: []),
21 | .testTarget(
22 | name: "GZMatchedTransformEffectTests",
23 | dependencies: ["GZMatchedTransformEffect"]),
24 | ],
25 | swiftLanguageVersions: [.v5]
26 | )
27 |
--------------------------------------------------------------------------------
/Sources/GZMatchedTransformEffect/SizePreference.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SizePreference.swift
3 | //
4 | //
5 | // Created by Gong Zhang on 2020/9/17.
6 | //
7 |
8 | import SwiftUI
9 |
10 | enum SizePreference: PreferenceKey {
11 | typealias Value = CGSize?
12 | static var defaultValue: CGSize? { nil }
13 |
14 | static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) {
15 | }
16 | }
17 |
18 | @available(iOS 13.0, macOS 10.15, watchOS 6.0, *)
19 | extension View {
20 | func retrieveSize(to binding: Binding) -> some View {
21 | self.background(
22 | GeometryReader { proxy in
23 | Color.clear
24 | .preference(key: SizePreference.self,
25 | value: proxy.size)
26 | }
27 | .onPreferenceChange(SizePreference.self, perform: { value in
28 | if binding.wrappedValue != value {
29 | binding.wrappedValue = value
30 | }
31 | })
32 | )
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Gong Zhang
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GZMatchedTransformEffect
2 |
3 |
4 |
5 | Create a smooth transition between any two SwiftUI Views. It is very similar to the built-in `.matchedGeometryEffect()` modifier, but the effect is much smoother.
6 |
7 | ## Installation
8 |
9 | ### Swift Package Manager
10 |
11 | ```swift
12 | dependencies: [
13 | .package(url: "https://github.com/gongzhang/GZMatchedTransformEffect", .upToNextMajor(from: "1.0.0"))
14 | ]
15 | ```
16 |
17 | ## Quick Start
18 |
19 | 1. Use the same `.matchedTransformEffect()` modifier on both views.
20 | 2. Control the appearance and disappearance of two views in a `withAnimation` block
21 |
22 | ```swift
23 | import SwiftUI
24 | import GZMatchedTransformEffect
25 |
26 | struct Example: View {
27 | @State private var flag = true
28 | @Namespace private var ns
29 |
30 | var view1: some View { ... }
31 | var view2: some View { ... }
32 |
33 | var body: some View {
34 | VStack {
35 | if flag {
36 | view1
37 | .fixedSize()
38 | .id("view1")
39 | .matchedTransformEffect(id: "transition", in: ns) // ⬅️
40 | }
41 |
42 | Spacer()
43 |
44 | if !flag {
45 | view2
46 | .fixedSize()
47 | .id("view2")
48 | .matchedTransformEffect(id: "transition", in: ns) // ⬅️
49 | }
50 |
51 | Button(action: { withAnimation { flag.toggle() } }) {
52 | Text("Toggle")
53 | }
54 | }
55 | }
56 | }
57 | ```
58 |
--------------------------------------------------------------------------------
/Sources/GZMatchedTransformEffect/MatchedTransformEffect.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MatchedTransformEffect.swift
3 | // MatchedTransformEffect
4 | //
5 | // Created by Gong Zhang on 2021/9/8.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @available(iOS 14.0, macOS 11.0, watchOS 7.0, *)
11 | struct MatchedTransformEffect: ViewModifier {
12 |
13 | var id: ID
14 | var namespace: Namespace.ID
15 |
16 | @State private var transformedSize: CGSize? = nil
17 | @State private var intrinsicSize: CGSize? = nil
18 |
19 | private var scale: CGFloat {
20 | guard let intrinsicSize = intrinsicSize, let transformedSize = transformedSize else {
21 | return 1
22 | }
23 |
24 | let wScale = transformedSize.width / max(intrinsicSize.width, 1)
25 | let hScale = transformedSize.height / max(intrinsicSize.height, 1)
26 |
27 | // balance two axes if they have different aspect ratios
28 | return (wScale + hScale) / 2
29 | }
30 |
31 | func body(content: Content) -> some View {
32 | content
33 | .scaleEffect(scale)
34 | .retrieveSize(to: $intrinsicSize)
35 | .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
36 | .retrieveSize(to: $transformedSize)
37 | .matchedGeometryEffect(id: id, in: namespace, properties: .frame)
38 | .fixedSize()
39 | }
40 |
41 | }
42 |
43 | @available(iOS 14.0, macOS 11.0, watchOS 7.0, *)
44 | extension View {
45 |
46 | /// Defines a transform effect between two views.
47 | ///
48 | /// Use this modifier on two different views (i.e. they have different view identitiy).
49 | /// When one of the views appears and the other disappears, a transition animation
50 | /// of scale and opacity will be generated between them.
51 | ///
52 | /// You may find the `matchedGeometryEffect()` very similar to this.
53 | /// The difference is that the `matchedGeometryEffect()` interpolates
54 | /// the frames between the two views, and triggers re-layout of the views at each frame.
55 | /// On the other hand, the `matchedTransformEffect()` uses scale transform
56 | /// and does not change view frames during the animation. This is often useful for text
57 | /// transition.
58 | ///
59 | /// ```swift
60 | /// struct Example: View {
61 | /// @State private var flag = true
62 | /// @Namespace private var ns
63 | ///
64 | /// var view1: some View { ... }
65 | /// var view2: some View { ... }
66 | ///
67 | /// var body: some View {
68 | /// VStack {
69 | /// if flag {
70 | /// view1
71 | /// .id("view1")
72 | /// .matchedTransformEffect(id: "bubbleTransition", in: ns)
73 | /// }
74 | ///
75 | /// if !flag {
76 | /// view2
77 | /// .id("view2")
78 | /// .matchedTransformEffect(id: "bubbleTransition", in: ns)
79 | /// }
80 | ///
81 | /// Button(action: { withAnimation { flag.toggle() } }) {
82 | /// Text("Toggle")
83 | /// }
84 | /// }
85 | /// }
86 | /// }
87 | /// ```
88 | ///
89 | /// - Important: Helpful hints:
90 | /// 1. The two views must have different identities, which can be specified explicitly with `id(_:)` if necessary.
91 | /// 2. The two views cannot appear at the same time.
92 | /// 3. Both views must have a deterministic size. This can be specified by `frame(width:height:)` or `fixedSize()`.
93 | /// 4. If the animation doesn't appear correctly, try wrapping a `ZStack` on the outer layer; this problem usually occurs in Xcode Preview.
94 | ///
95 | /// - Parameters:
96 | /// - id: The identifier, used to define a transition between two views.
97 | /// - namespace: The namespace in which defines the `id`.
98 | ///
99 | public func matchedTransformEffect(id: ID, in namespace: Namespace.ID) -> some View {
100 | self.modifier(MatchedTransformEffect(id: id, namespace: namespace))
101 | }
102 | }
103 |
104 | @available(iOS 15.0, macOS 12.0, watchOS 8.0, *)
105 | struct MatchedTransformEffect_Preview: PreviewProvider {
106 |
107 | struct Example: View {
108 |
109 | @State private var flag = true
110 | @Namespace private var ns
111 |
112 | var view1: some View {
113 | Text("👋 Hello")
114 | .foregroundColor(.white)
115 | .font(.body)
116 | .padding(.horizontal, 5)
117 | .padding(.vertical, 2)
118 | .background(Capsule().fill(Color.blue))
119 | .fixedSize()
120 | }
121 |
122 | var view2: some View {
123 | Text("World 🎉")
124 | .foregroundColor(.white)
125 | .font(.largeTitle)
126 | .padding(.vertical, 10)
127 | .padding(.horizontal, 20)
128 | .background(Capsule().fill(Color.green))
129 | }
130 |
131 | var body: some View {
132 | VStack {
133 | HStack {
134 | VStack {
135 | Text(".matchedTransformEffect 👏")
136 | ZStack {
137 | Group {
138 | if flag {
139 | view1
140 | .fixedSize()
141 | .id("bubble1")
142 | .matchedTransformEffect(id: "bubbleTransition", in: ns)
143 | }
144 | }
145 | .frame(maxHeight: .infinity, alignment: .top)
146 |
147 | Group {
148 | if !flag {
149 | view2
150 | .fixedSize()
151 | .id("bubble2")
152 | .matchedTransformEffect(id: "bubbleTransition", in: ns)
153 | }
154 | }
155 | .frame(maxHeight: .infinity, alignment: .bottom)
156 | }
157 | .frame(maxWidth: .infinity)
158 | }
159 |
160 | Divider()
161 |
162 | VStack {
163 | Text(".matchedGeometryEffect ❌")
164 | ZStack {
165 | Group {
166 | if flag {
167 | view1
168 | .id("bubble3")
169 | .matchedGeometryEffect(id: "bubble", in: ns)
170 | }
171 | }
172 | .frame(maxHeight: .infinity, alignment: .top)
173 |
174 | Group {
175 | if !flag {
176 | view2
177 | .id("bubble4")
178 | .matchedGeometryEffect(id: "bubble", in: ns)
179 | }
180 | }
181 | .frame(maxHeight: .infinity, alignment: .bottom)
182 | }
183 | .frame(maxWidth: .infinity)
184 | }
185 | }
186 | .font(.body.monospaced())
187 |
188 | Button(action: { withAnimation(.default.speed(0.25)) { flag.toggle() } }) {
189 | Text("Toggle")
190 | }
191 | }
192 | }
193 | }
194 |
195 | static var previews: some View {
196 | ZStack { // workaround SwiftUI animation issue in Xcode Preview
197 | Example()
198 | }
199 | .padding()
200 | }
201 | }
202 |
203 |
--------------------------------------------------------------------------------