SwiftUI Interaction Challenge [2]
Previous post
For the last three months, I had the chance to work on an app drenched in cutting-edge UI and UX experiments. This seriese will be a chronicle of the hurdles I encountered and how I overcame them.
You can explore the finished app at here. Be aware that it’s not yet available in English, which may make it challenging to use.
Challenge №2 : Transitioning objects with synchronized geometry
Problem
We have successfully made the iPhone draw five circles of various sizes, all of which are externally tangent to each other. It’s now time to add the interactions for when each circle is tapped.
As shown in the example on Figma prototype above, after tapping a circle, the app should transition to a detailed information screen about that circle. However, when the screen appears, the transition of the circle should use move effect, so its position changes with synchronized geometry.
Thinking process
- Add tap gesture
Fundamentally, this kind of problem requires the use of SwiftUI
’s matchedGeometryEffect
. Before that, let’s build a prototype view without any effects.
There are two things we have done here.
First, for convenience in management and readability, I separated each Circle
into a view building method circleItem
. Second, I added a tap gesture to each circleItem
so that every time it’s tapped, it performs the action of showing the screen with the enlarged circle (detailView
).
2. Blur the background
In here, we need to add a blur effect to the background. Unfortunately, while SwiftUI has the capability to blur a component itself, it lacks the feature to blur its own background.
Therefore, using SwiftUI’s blur, you would get a strange result like the one shown in the example abo. To avoid this, we must use UIKit’s UIBlurEffect
. I created the SwiftUI view called BackgroundBlurringView
by wrapping it with UIViewRepresentable
, and the code for it is here.
if let item = showItem {
// Wrong!
// Color.black.opacity(0.5).blur(radius: 5)
// Use this
BackgroundBlurringView(style: .light)
.ignoresSafeArea()
detailView(data: item)
.onTapGesture {
self.showItem = nil
}
.padding(.horizontal, 30)
}
You also have the option to apply a blur effect directly to the ForEach
, but keep in mind, this can get a bit annoying, especially when you need to manage additional boolean conditions like showBlurView
.
3. Give effect
Before giving matchedGeometryEffect
, we need to specify the namespace
and ID
that will be passed as parameters to this method.
The namespace can easily be created with @Namespace
, and for the animation ID, we can use the UUID
we fortunately assigned to CircleData
. In addition, to use animation(_:value:)
, we just need to declare Equatable
additionally to CircleData
.
@Namespace var animation // Added
var body: some View {
ZStack { ... }
.animation(.spring, value: showItem) // Added
}
func circleItem(data: CircleData) -> some View {
Circle()
...
// Must be added before `frame`
.matchedGeometryEffect(id: data.id.uuidString, in: animation) // Added
.frame(width: data.radius * widthUnit)
.offset(
x: data.coordinate.x * widthUnit / 2,
y: data.coordinate.y * widthUnit / 2)
}
func detailView(data: CircleData) -> some View {
Circle()
...
.matchedGeometryEffect(id: data.id.uuidString, in: animation) // Added
}
Sometimes, after applying matchedGeometryEffect
, you may end up with an unexpected effect like the example on the left. To use matchedGeometryEffect
properly, its declaration position is also important.
One ground rule is that you should declare the effect before the frame
. If you look at the location marked //Added
on the left example, you can see it's difference of position from the code on the right. That’s why your animation could be broken.
4. Refine the effect
While it seems to be working nicely here, there’s still a bit more to be improved. To examine our effect in greater detail, let’s look at it closely in a slo-mo GIF:
As you can see, when the large circle is dismissed, the animation is somewhat unnatural because the circle disappears immediately upon dismiss, so the color-changing effect is not visible.
This is a common issue that occurs when implementing transition animations in SwiftUI’s ZStack
, where a component disappears and its zIndex
changes to the lowest value.
This causes the component to be covered before the animation ends, resulting in an animation that appears to cut off abruptly.
It can be resolved by explicitly specifying the zIndex
like so:
ZStack {
ForEach(circlesData) { ... }
.zIndex(ZIndex.low.rawValue) // Added
if let item = showItem {
BackgroundBlurringView(style: .light)
...
.zIndex(ZIndex.middle.rawValue) // Added
detailView(data: item)
...
.zIndex(ZIndex.high.rawValue) // Added
}
}
...
// Added
private enum ZIndex: CGFloat {
case high = 3
case middle = 2
case low = 0
}
Now we have a view that operates perfectly as expected!
What’s next?
The next part involves implementing a custom bottom sheet and creating a view where the size of the circle changes as the bottom sheet moves up and down.
It may be a bit difficult, but it’s also an interesting aspect. I’m looking forward to it. Stay tuned for what’s next 🔥🔥🔥
Codes
You can check the Xcode project used in this post right here 👇👇👇