├── .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 | --------------------------------------------------------------------------------