Geometry Reader
GeometryReader in SwiftUI by definition is a container view that provides access to the size and coordinate space of its view. But there should be some parent view connection, so let’s put the definition like this: A container view that provides its content with a GeometryProxy object that contains size and coordinate space information about its parent view. It’s useful for creating views that need to adjust their size or position based on the space available to them. Unlike VStack/HStack, geometryReader fights for the space available, fills the whole available space, and gives the proxy to reach the size and the coordinate. Plus, geometryReader’s origin is the top leading edge, unlike VStack or other forms which have the origin of center.
To see in action,
I am going to use the Fictional AlbumPicker of the previous post and update the photo detail view by having the whole image and adding the favorite button to the bottom center. Here the key is using the position property of the current view(through .local) and the parent view(through .global) positions:
proxy.frame(in: .local)
proxy.frame(in: .global)
What are those local and global?
When we’re moving stuff around in SwiftUI, we keep hearing about local and global spaces. Think of it like playing on your home turf versus away games.
Local Space is your home turf. It’s all about where things are in relation to their immediate surroundings. Imagine you’re arranging furniture in your living room; you’re working within the local space of the room, making sure everything fits nicely together.
Global Space is the away game. It’s the big picture—how your view fits in the entire screen of your app. If local is arranging furniture in your living room, global is figuring out how your house sits on the entire block.
And there’s this cool thing we can do: we can create our own mini worlds (custom coordinate spaces) to help different parts of our app talk to each other, even if they’re not directly connected.
Here is how I end up with .local & .global:
struct PhotoDetailView: View {
@ObservedObject var viewModel: PhotoDetailViewModel
var body: some View {
GeometryReader { proxy in
if let uiImage = viewModel.asset.loadUIImage() {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
}
HStack {
Button(action: {
viewModel.toggleFavorite()
}) {
Image(systemName: viewModel.isFavorite ? "heart.fill" : "heart")
}
}
.position(CGPoint(x: proxy.frame(in: .local).midX,
y: CGFloat(proxy.frame(in: .global).maxY) - 50))
}
.padding()
.navigationTitle("Photo Detail")
}
}
Here I can use the Custom CoordinateSpaceProtocol to get the button height and place it at the very end of the view:
.position(CGPoint(x: proxy.frame(in: .local).midX, y: proxy.frame(in: .named("Image")).height))
The custom name above gets the hashable name of the view and uses its coordinates and size. For this specific example, all .local, global, and custom saves the same purpose so I will stick with the .local.
Git diff: https://github.com/rozeridilar/Fictional-Album-Picker/commit/97860c2923550294d8fad3a332e4ad9b70e84a05
Matched Geometry Effect
Now I am going to add some transition to the heart icon so that when the user likes the photo, the heart will get bigger and come into the center while when it is not added into the favorites, it will stay smaller and in the bottom center. And it has to be a smooth transition, so I am going to use matchedGeometryEffect(id:in:properties:anchor:isSource:).
The matched geometry effect is most visually impactful when moving between different views or positions on the screen. It allows for smooth animations when an element changes its position, size, or other properties across different views or states.
In the context of our PhotoDetailView, I’d adjust the layout so that when the heart is a favorite, it not only changes appearance but also moves to a more prominent position on the screen, such as the center, while scaling up. This movement and transformation will be animated using the matchedGeometryEffect.
- Add @Namespace Variable: First, I need to declare a namespace variable within my view. This @Namespace is used by the matched geometry effect to identify corresponding views across different hierarchies.
- Apply MatchedGeometryEffect: I’ll use the .matchedGeometryEffect(id:in:) modifier on the heart icon. I’ll give it a unique ID and the namespace that was previously declared.
- Animate State Change: To make the heart icon grow when liked, I can conditionally adjust its scale based on the viewModel.isFavorite state. Wrap the state change in a withAnimation block to animate the transition.
struct PhotoDetailView: View {
@ObservedObject var viewModel: PhotoDetailViewModel
@Namespace private var heartAnimationNamespace
private let heartIconId = "heartIcon"
var body: some View {
GeometryReader { proxy in
ZStack {
if let uiImage = viewModel.asset.loadUIImage() {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: proxy.size.width)
}
if viewModel.isFavorite {
Image(systemName: "heart.fill")
.matchedGeometryEffect(id: heartIconId, in: heartAnimationNamespace)
.font(.system(size: 60)) // Make the icon larger
.foregroundColor(.red)
.position(CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2))
.onTapGesture {
withAnimation {
viewModel.toggleFavorite()
}
}
}
}
if !viewModel.isFavorite {
Button(action: {
withAnimation {
viewModel.toggleFavorite()
}
}) {
Image(systemName: "heart")
.matchedGeometryEffect(id: heartIconId, in: heartAnimationNamespace)
}
.position(CGPoint(x: proxy.frame(in: .local).midX, y: proxy.frame(in: .local).maxY))
}
}
.navigationTitle("Photo Detail")
}
}
In this example, the heart icon is wrapped in a ZStack with the image. When viewModel.isFavorite is true, the heart icon (now filled and larger) is positioned in the center of the screen using position. The .matchedGeometryEffect modifier is applied to both states of the heart icon (center and original position) with the same ID and namespace to enable smooth animation between these positions.
Performance Considerations: Keeping it Smooth
We all love a smooth-running app, right? But with great power comes great responsibility. Using GeometryReader and matchedGeometryEffect can make our apps do some awesome stuff, but we gotta be careful not to overdo it.
- GeometryReader: It’s like having a map of everything. Super useful, but if we’re checking the map at every turn, we’re going to slow down. Use it when you need it, but don’t let it become a crutch.
- MatchedGeometryEffect: This one’s like magic, making things move smoothly from point A to point B. But casting too many spells at once can tire out even the best wizards. So, use this magic wisely to keep your app running like a dream.
In short, keep things light and straightforward. Too much of anything can bog down your app, making it less like a sprinter and more like a snail. Aim for that sweet spot where everything feels just right.
References:
https://developer.apple.com/documentation/swiftui/geometryreader
Leave a comment