|
| 1 | +--- |
| 2 | +title: Making MotionEffects with SwiftUI |
| 3 | +thumbnail: |
| 4 | +description: Have you ever tried applying motion effects to SwiftUI views? No? Come with me! |
| 5 | +published: true |
| 6 | +header: /motion/header.jpg |
| 7 | +layout: ArticleLayout |
| 8 | +date: 2020-08-01 00:00:00 |
| 9 | +translated: Kayque Moraes |
| 10 | +language: en |
| 11 | +--- |
| 12 | + |
| 13 | +The small details make all the difference, and small details make me more happy than they should, because they bring delight, make the small interactions that we have with our devices a little cooler, more fun. And what else can make us happy as developers? SwiftUI, of course! |
| 14 | + |
| 15 | +One of the coolest and super smooth interactions for us to implement in our UIKit apps is the `UIInterpolationMotionEffect`, which gives that parallax effect when we move the phone, better known as the [Perspective effect](https://support.apple.com/pt-br/HT200285) that we can apply on the background images of our iPhones and iPads. But when I tried to add one in my SwiftUI View, I got caught by this: |
| 16 | + |
| 17 | + |
| 18 | + |
| 19 | +Then I discovered, searching through the documentation, that there is no way to place an effect like this natively with SwiftUI! My first thought was to create a `UIViewRepresentable` that had my View inside a SwiftUI container, making a SwiftUI -> UIKit -> SwiftUI bridge, which I tried to do, without much success. |
| 20 | + |
| 21 | +So I decided to see if it was possible to read the device’s sensor and manually recreate this behavior, creating a view that seems more "native" to SwiftUI. And with [CoreMotion](https://developer.apple.com/documentation/coremotion) all of this is not only possible but also very easy, and it will be easier for us to create composite effects, such as elements that move at different speeds according to my device’s movement. |
| 22 | + |
| 23 | +To access the device's accelerometer/gyroscope data, we need a `CMMotionManager` instance, that [according to Apple’s recommendations](https://developer.apple.com/documentation/coremotion/cmmotionmanager), needs to be shared: |
| 24 | + |
| 25 | +```swift |
| 26 | +import CoreMotion |
| 27 | + |
| 28 | +extension CMMotionManager { |
| 29 | + static var shared = CMMotionManager() |
| 30 | +} |
| 31 | +``` |
| 32 | + |
| 33 | +With `CMMotionManager`, we can access the accelerometer, gyroscope, magnetometer, and device-motion, which is a multitude of data inferred by the Core Motion algorithms. Because I want to make my effect with the device’s rotation, I will use the gyroscope, which measures each rotation axis of the device. First, I will create a `View` that is going to handle this reading and, just like the [GeometryReader](https://developer.apple.com/documentation/swiftui/geometryreader) does, expose this data to child views, and make the necessary `MotionManager` settings in it, like the data update interval (with an update frequency of 30x per second), the start/end of data reading, and the publisher where we will read and process this data: |
| 34 | + |
| 35 | +```swift |
| 36 | +struct MotionReader<Content>: View where Content: View { |
| 37 | + |
| 38 | + private let contentView: () -> Content |
| 39 | + private let motionManager: CMMotionManager = .shared |
| 40 | + private let timer = Timer.publish(every: 1/30, on: .main, in: .common).autoconnect() |
| 41 | + |
| 42 | + init(@ViewBuilder content: @escaping () -> Content) { |
| 43 | + contentView = content |
| 44 | + } |
| 45 | + |
| 46 | + var body: some View { |
| 47 | + contentView() |
| 48 | + .onAppear { |
| 49 | + self.motionManager.gyroUpdateInterval = 1/30 |
| 50 | + self.motionManager.startGyroUpdates() |
| 51 | + } |
| 52 | + .onDisappear { |
| 53 | + self.motionManager.stopAccelerometerUpdates() |
| 54 | + } |
| 55 | + .onReceive(timer) { _ in } |
| 56 | + } |
| 57 | +} |
| 58 | +``` |
| 59 | + |
| 60 | +And then we can start reading our manager’s data, changing `onReceive`. I also created a `struct` that encapsulates the gyroscope data (x,y, and z axes) to pass it to our child views: |
| 61 | + |
| 62 | +``` |
| 63 | +/// MotionReader.swift |
| 64 | + private let contentView: (MotionProxy) -> Content |
| 65 | + @State private var currentOffset: MotionProxy = .zero |
| 66 | +... |
| 67 | + contentView(currentOffset) |
| 68 | +... |
| 69 | + .onReceive(timer) { _ in |
| 70 | + guard let data = self.motionManager.gyroData else { return } |
| 71 | + let rate = data.rotationRate |
| 72 | + self.currentOffset = MotionProxy(x: rate.x, y: rate.y, z: rate.z) |
| 73 | + } |
| 74 | +
|
| 75 | +/// ContentView.swift |
| 76 | +struct ContentView: some View { |
| 77 | + var body: some View { |
| 78 | + MotionReader { proxy in |
| 79 | + CardView() |
| 80 | + .offset(x: proxy.x, y: proxy.y) |
| 81 | + } |
| 82 | + } |
| 83 | +} |
| 84 | +``` |
| 85 | + |
| 86 | +<video loop autoplay muted> |
| 87 | + <source type="video/mp4" src="/images/motion/effectless.mp4"> |
| 88 | +</video> |
| 89 | +<p> |
| 90 | +<span class="caption muted">I had to shake <strong>a lot</strong> to get this little bit of change</span> |
| 91 | +</p> |
| 92 | + |
| 93 | +Doing all this, we must run the app on the device (the simulator doesn’t have CoreMotion support, unfortunately), and then we can attest that almost nothing has changed, and the little that has is not noticeable when we use the device itself, being only visible on a video on a still screen. Furthermore, the movement is very shaky. |
| 94 | + |
| 95 | +The values that the gyroscope emits are very low, so we need to manipulate them in a way that the `offset` of the card is altered with more intensity, and we can set some limits, like the `minimumRelativeValue` and `maximumRelativeValue` properties of the `UIInterpolationMotionEffect`, in addition to adding a basic animation to the view, so that the shaking stops: |
| 96 | + |
| 97 | +``` |
| 98 | + private let strength: Double |
| 99 | + private let minimum: Double |
| 100 | + private let maximum: Double |
| 101 | +... |
| 102 | + init(motionRange: ClosedRange<Double> = (-5.0...5.0), |
| 103 | + motionStrength: Double = 1, |
| 104 | + @ViewBuilder content: @escaping (MotionProxy) -> Content) { |
| 105 | + minimum = motionRange.lowerBound |
| 106 | + maximum = motionRange.upperBound |
| 107 | + contentView = content |
| 108 | + strength = motionStrength * 5 |
| 109 | + } |
| 110 | +
|
| 111 | + var body: some View { |
| 112 | + contentView() |
| 113 | + .animation(.linear) |
| 114 | + .onAppear { |
| 115 | + self.motionManager.gyroUpdateInterval = 1/30 |
| 116 | + self.motionManager.startGyroUpdates() |
| 117 | + } |
| 118 | + .onDisappear { |
| 119 | + self.motionManager.stopAccelerometerUpdates() |
| 120 | + } |
| 121 | + .onReceive(timer) { _ in |
| 122 | + guard let data = self.motionManager.gyroData else { return } |
| 123 | + let rate = data.rotationRate |
| 124 | + self.currentOffset = calculateOffset(x: rate.x, y: rate.y, z: rate.z) |
| 125 | + } |
| 126 | + } |
| 127 | +
|
| 128 | + private func calculateOffset(x: Double, y: Double, z: Double) -> MotionProxy { |
| 129 | + let xAxis = max(minimum, min((x * strength), maximum)) |
| 130 | + let yAxis = max(minimum, min((y * strength), maximum)) |
| 131 | + return MotionProxy(x: xAxis, y: yAxis, z: z) |
| 132 | + } |
| 133 | +``` |
| 134 | + |
| 135 | +<video loop autoplay muted> |
| 136 | + <source type="video/mp4" src="/images/motion/smooth-comp.mp4"> |
| 137 | +</video> |
| 138 | +<p> |
| 139 | + <span class="caption muted">It's much smoother now, right? I was shaking the iPad again</span> |
| 140 | +</p> |
| 141 | + |
| 142 | +Now in addition to having a much smoother animation, we can set all the movement values so that the effect is exactly the way we want! But when turning the device, we can see that the movement is not going in the same direction when we move our device. Why? The x and y coordinates of my view and the coordinates that the gyroscope is emitting are not consistent in each orientation, so we need to calculate and swap the values. |
| 143 | + |
| 144 | +For this, we need to create a way to detect when the device changes orientation and change the offset calculation accordingly, and this is very simple: |
| 145 | + |
| 146 | +``` |
| 147 | +/// DeviceOrientation.swift |
| 148 | +final class DeviceOrientation: ObservableObject { |
| 149 | + private var observer: AnyCancellable? |
| 150 | + @Published var deviceOrientation: UIDeviceOrientation |
| 151 | +
|
| 152 | + init() { |
| 153 | + deviceOrientation = UIDevice.current.orientation |
| 154 | + observeOrientation() |
| 155 | + } |
| 156 | +
|
| 157 | + private func observeOrientation() { |
| 158 | + observer = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification) |
| 159 | + .compactMap({ $0.object as? UIDevice}) |
| 160 | + .sink { [weak self] device in |
| 161 | + self?.deviceOrientation = device.orientation |
| 162 | + } |
| 163 | + } |
| 164 | +} |
| 165 | +
|
| 166 | +/// MotionReader.swift |
| 167 | + @ObservedObject var deviceOrientation = DeviceOrientation() |
| 168 | +... |
| 169 | + .onReceive(timer) { _ in |
| 170 | + guard let data = self.motionManager.gyroData else { return } |
| 171 | + let rate = data.rotationRate |
| 172 | + self.currentOffset = calculateOffsetForCurrentOrientation(x: rate.x, y: rate.y, z: rate.z) |
| 173 | + } |
| 174 | +
|
| 175 | + private func calculateOffsetForCurrentOrientation(x: Double, y: Double, z: Double) -> MotionProxy { |
| 176 | + let xAxis = max(minimum, min((x * strength), maximum)) |
| 177 | + let yAxis = max(minimum, min((y * strength), maximum)) |
| 178 | +
|
| 179 | + switch deviceOrientation.deviceOrientation { |
| 180 | + case .portrait: |
| 181 | + return MotionProxy(x: yAxis, y: xAxis, z: z) |
| 182 | + case .portraitUpsideDown: |
| 183 | + return MotionProxy(x: yAxis, y: -xAxis, z: z) |
| 184 | + case .landscapeLeft: |
| 185 | + return MotionProxy(x: -xAxis, y: -yAxis, z: z) |
| 186 | + case .landscapeRight: |
| 187 | + return MotionProxy(x: -xAxis, y: yAxis, z: z) |
| 188 | + case .unknown, .faceDown, .faceUp: |
| 189 | + return MotionProxy(x: xAxis, y: yAxis, z: z) |
| 190 | + @unknown default: |
| 191 | + return MotionProxy(x: xAxis, y: yAxis, z: z) |
| 192 | + } |
| 193 | + } |
| 194 | +``` |
| 195 | + |
| 196 | +Finally, our reader should respect any setting that the user can make on the device, such as [low power mode](https://developer.apple.com/documentation/foundation/processinfo/1617047-islowpowermodeenabled) and accessibility configuration to [reduce motion](https://developer.apple.com/videos/play/wwdc2019/244/): |
| 197 | + |
| 198 | +``` |
| 199 | +/// MotionReader.swift |
| 200 | + @Environment(\.accessibilityReduceMotion) var isReduceMotionOn: Bool |
| 201 | +... |
| 202 | + .onAppear { |
| 203 | + guard self.shouldEnableMotion else { return } |
| 204 | + self.motionManager.gyroUpdateInterval = 1/30 |
| 205 | + self.motionManager.startGyroUpdates() |
| 206 | + } |
| 207 | +... |
| 208 | + .onReceive(timer) { publisher in |
| 209 | + guard self.shouldEnableMotion, |
| 210 | + let data = self.motionManager.gyroData else { return } |
| 211 | +
|
| 212 | + let rate = data.rotationRate |
| 213 | + self.currentOffset = self.calculateOffsetForCurrentOrientation(x: rate.x, y: rate.y, z: rate.z) |
| 214 | + } |
| 215 | +... |
| 216 | + private var shouldEnableMotion: Bool { |
| 217 | + !ProcessInfo.processInfo.isLowPowerModeEnabled && |
| 218 | + motionManager.isAccelerometerAvailable && |
| 219 | + !isReduceMotionOn |
| 220 | + } |
| 221 | +``` |
| 222 | + |
| 223 | +As a cherry on top to make it easier to use our Reader, we will create a `View` extension that does the entire parallaxing automatically for us! |
| 224 | + |
| 225 | +``` |
| 226 | +extension View { |
| 227 | + func motionEffect(scale: CGFloat = 1.2, |
| 228 | + range: ClosedRange<Double> = (-5.0...5.0), |
| 229 | + strength: Double = 1) -> some View { |
| 230 | + MotionReader(motionRange: range, motionStrength: strength) { proxy in |
| 231 | + self |
| 232 | + .scaleEffect(scale) |
| 233 | + .offset(x: proxy.x, y: proxy.y) |
| 234 | + } |
| 235 | + } |
| 236 | +} |
| 237 | +``` |
| 238 | + |
| 239 | +And with this, our code from the beginning works perfectly, the way that I wanted it to work :) |
| 240 | + |
| 241 | + |
| 242 | + |
| 243 | +The complete project with the card example and the gyroscope reader is on my [GitHub](https://github.com/loloop/SwiftInMotion), and I would like to thank [@Alan Pégoli](https://twitter.com/alanpegoli) who did the beta test and pointed out a glaring flaw in the article :) |
| 244 | + |
| 245 | + |
0 commit comments