SwiftUI Interaction Challenge [2]

Challenge №2 : Transitioning objects with synchronized geometry

Enebin
5 min readNov 8, 2023

Previous post

Photo by Josh Boak on Unsplash

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

  1. 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.

🔗 Full codes

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
}
(left) slo-mo / (right) normal

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 🔥🔥🔥

--

--