3.
View Transitions
Written by Irina Galata
In the previous chapter, you started working on a sports-themed app to sell game tickets. You managed to improve its pull-to-refresh animation, turning the system loading wheel into a fun and memorable interaction.
In this chapter, you’ll work on a new screen that contains a game’s details as the next step toward the ticket purchase. You’ll implement popular UI concepts like list filters, a collapsing header view and floating action buttons. Since this is an animations book, you’ll also enhance them via various types of transitions, which you already got to briefly play with in the first chapter. You’ll also get to roll up your sleeves and craft a custom transition.
Getting Started
You can continue working on the project from the previous chapter or use the starter project from this chapter’s materials.
To pick up where you left off at the previous chapter, grab the EventDetails folder and the Asset catalog, Assets.xcassets, from this chapter’s starter project and add them to your current project.
Since you’ll work on several different components this time, append the following values inside your Constants
enum, over at Constants.swift:
static let spacingS = 8.0
static let spacingM = 16.0
static let spacingL = 24.0
static let cornersRadius = 24.0
static let iconSizeS = 16.0
static let iconSizeL = 24.0
static let orange = Color("AccentColor")
static let minHeaderOffset = -80.0
static let headerHeight = 220.0
static let minHeaderHeight = 120.0
static let floatingButtonWidth = 110.0
If you’re starting from the starter project, these files and values will already be part of your project.
Implementing Filter View
Head over to the already familiar ContentView.swift.
In the first iteration of the events screen, the navigation is somewhat cumbersome: the only way to find an event is to scroll to it, which can take a while. To make it more user-friendly, you’ll implement a filtering functionality. For example, a user who only wants to see basketball can filter out all other games.
First, create a new SwiftUI file named FilterView.swift, and add the following properties to the generated struct:
@Binding var selectedSports: Set<Sport>
var isShown: Bool
private let sports = Sport.allCases
Before moving on, you’ll fix the preview code so your code compiles. Replace the view in the preview code with:
FilterView(selectedSports: .constant([]), isShown: true)
Then, back in FilterView
, below its body
, add a method to build a view for each option:
func item(for sport: Sport) -> some View {
Text(sport.string)
.frame(height: 48)
.foregroundColor(selectedSports.contains(sport) ? .white : .primary)
.padding(.horizontal, 36)
}
Now, you’ll add a bit more style. Add the following .background
modifier to the Text
in item(for:)
:
.background {
ZStack {
RoundedRectangle(cornerRadius: Constants.cornersRadius)
.fill(
selectedSports.contains(sport)
? Constants.orange
: Color(uiColor: UIColor.secondarySystemBackground)
)
.shadow(radius: 2)
RoundedRectangle(cornerRadius: Constants.cornersRadius)
.strokeBorder(Constants.orange, lineWidth: 3)
}
}
This code makes the item appear as a rounded rectangle outlined by an orange stroke. If selectedSports
contains the sport the user picked, it paints the view orange to indicate it was selected.
Now, replace the view’s body
with a ZStack
to hold all the sports options:
ZStack(alignment: .topLeading) {
if isShown {
ForEach(sports, id: \.self) { sport in
item(for: sport)
.padding([.horizontal], 4)
.padding([.top], 8)
}
}
}
.padding(.top, isShown ? 24 : 0)
With the code above, you stack all the filtering items on top of each other. To build a grid out of them, you need to define each item’s location relative to its neighbors.
Aligning Subviews With Alignment Guides
Using the alignmentGuide(_:computeValue:)
view modifier, you can shift a component relative to the positions of its sibling views. Since you want to implement a grid, you’ll adjust the item’s horizontal and vertical alignment guides.
Computations like this require iterating over all the elements to accumulate the total values of the horizontal and vertical shift, so add the following variables above the ZStack
you added in the previous step, at the top of the body
:
var horizontalShift = CGFloat.zero
var verticalShift = CGFloat.zero
You’ll start with the horizontal alignment. Add .alignmentGuide(_:computeValue:)
to the item
below its padding:
// 1
.alignmentGuide(.leading) { dimension in
// 2
if abs(horizontalShift - dimension.width) > UIScreen.main.bounds.width {
// 3
horizontalShift = 0
verticalShift -= dimension.height
}
// 4
let currentShift = horizontalShift
// 5
horizontalShift = sport == sports.last ? 0 : horizontalShift - dimension.width
return currentShift
}
Here’s a step-by-step explanation:
-
First, you tell SwiftUI you want to make a computation to change the
.leading
alignment of a filter option. Inside the closure, you receive its dimensions, which will help calculate the alignment. -
You check whether the current item still fits horizontally.
-
If it doesn’t, you move it to the next “row” by setting the horizontal shift to 0, which places it at the left corner of the parent container. Additionally, you deduct the view’s height from the vertical alignment to move the element down, forming a new row.
-
Then, you assign the current item’s alignment value to a variable.
-
You deduct the current view’s width from the alignment, which the next item in the loop will use.
Note: Although it may appear confusing at first, to move a view to the right, you need to move its horizontal alignment guide to the left. Therefore you deduct a view’s width from the alignment value. Once the alignment guide moves to the left, SwiftUI aligns it with the alignment guides of the view’s siblings by moving the view to the right.
Now, add alignmentGuide(_:computeValue:)
to adjust the vertical alignment right below the previous one:
// 1
.alignmentGuide(.top) { _ in
let currentShift = verticalShift
// 2
verticalShift = sport == sports.last ? 0 : verticalShift
return currentShift
}
Here’s a code breakdown:
- This time, you adjust the
.top
alignment guide. - Unless the current element is the last one, assign the value calculated alongside the horizontal alignment above. Otherwise, reset the shift value to
0
.
Now you’ll handle the user’s selection. Add a new method to FilterView
:
private func onSelected(_ sport: Sport) {
if selectedSports.contains(sport) {
selectedSports.remove(sport)
} else {
selectedSports.insert(sport)
}
}
This code simply adds the sport to selectedSports
or removes it if selectedSports
already contains it.
Then, wrap your entire item(for:)
with a Button
and call your new onSelected
method, so it looks similar to the following:
Button {
onSelected(sport)
} label: {
item(for: sport)
// padding and alignment guide modifiers
}
Filtering List Content
To use your new component, you’ll have to adapt ContentView
a bit. Open ContentView.swift and add these new properties to it:
@State var filterShown = false // 1
@State var selectedSports: Set<Sport> = [] // 2
@State var unfilteredEvents: [Event] = [] // 3
Here’s what you’ll use each property for:
- You’ll use
filterShown
to toggle the visibility ofFilterView
. -
selectedSports
is a set where you’ll keep the selected sports. Later, changing this property will filter the sports events. - To reset the filter, you’ll keep the original array in
unfilteredEvents
.
Next, add a method to ContentView
, which is responsible for filtering the events:
func filter() {
events = selectedSports.isEmpty
? unfilteredEvents
: unfilteredEvents.filter { selectedSports.contains($0.team.sport) }
}
To prevent the pull-to-refresh from breaking the filter functionality, replace the code inside update()
with:
unfilteredEvents = await fetchMoreEvents(toAppend: events)
filter()
Add a toolbar item on the view’s primary ScrollView
, using the toolbar(content:)
modifier:
.toolbar {
// 1
ToolbarItem {
Button {
// 2
filterShown.toggle()
} label: {
Label("Filter", systemImage: "line.3.horizontal.decrease.circle")
.foregroundColor(Constants.orange)
}
}
}
Here’s a code breakdown:
- Inside
.toolbar
, you pass the toolbar items you want to display on top of the screen. You add only one primary action displayed as a filter icon. - Once a user taps it, you toggle
filterShown
.
To trigger the filter, you’ll use the view modifier .onChange(of:)
to listen to the changes to selectedSports
. Add the following modifier to ScrollView
:
.onChange(of: selectedSports) { _ in filter() }
Finally, wrap the LazyVStack
holding the event views into another VStack
and add FilterView
on top so that the structure looks like this:
VStack {
FilterView(selectedSports: $selectedSports, isShown: filterShown)
.padding(.top)
.zIndex(1)
LazyVStack { ... }
}
Make sure the .animation
and the .offset
modifiers are attached to the outer VStack
so the filters and pull-to-refresh won’t overlap.
Run the app, and tap the filter button in the navigation bar to see the new feature:
The functionality is there, but it’s not very fun to use. The filter view abruptly moves the events container down, which doesn’t look neat.
But it’s a piece of cake to make it smooth with SwiftUI’s transitions! Next, you’ll add a basic transition to your component.
Applying Basic Transitions
In SwiftUI, a transition is a movement that occurs as you add or remove a view in the rendering tree. It animates only in the context of a SwiftUI’s animation.
Since you already modify ContentView
’s layout by showing and hiding the filter view and updating the content of the events
set, only two components are missing: transitions and animations.
Still inside ContentView.swift, wrap the contents of filter()
with withAnimation(_:_:)
and pass some bouncy animation there:
withAnimation(
.interpolatingSpring(stiffness: 30, damping: 8)
.speed(1.5)
) {
events = selectedSports.isEmpty
? unfilteredEvents
: unfilteredEvents.filter { selectedSports.contains($0.team.sport)
}
}
Modifying the events
value inside withAnimation
lets SwiftUI animate every view’s update that depends on the events
property.
Next, inside ForEach
, replace EventView
with:
// 1
EventView(event: event)
// 2
.transition(.scale.combined(with: .opacity))
Here, you:
- Create the event view just as you did before.
- Attach a scale transition to
EventView
and combine it with an opacity transition.
This changes the animation of EventView
from easing into the view to scaling and slowly fading in.
Build and run, then filter by any sport to see the new transition.
Note: It’s sometimes preferable using
VStack
instead ofLazyVStacks
for animated content, since the lazy nature of the latter means the elements you want to animate aren’t necessarily available yet, which can cause the animation the look sloppy or stuck.
Crafting Custom Transitions
Your next goal is to animate FilterView
, which gives you an opportunity to try out some more advanced transitions. With SwiftUI, you can create a custom modifier to animate the transition between the active and inactive (i.e. identity) states of your FilterView
.
Back in FilterView.swift, add the following code to the bottom of the file:
struct FilterModifier: ViewModifier {
// 1
var active: Bool
// 2
func body(content: Content) -> some View {
content
// 3
.scaleEffect(active ? 0.75 : 1)
// 4
.rotationEffect(.degrees(active ? .random(in: -25...25) : 0), anchor: .center)
}
}
This code creates a struct named FilterModifier
that conforms to ViewModifier
. In the code above:
- You add an
active
property so you can animate the change between it’strue
andfalse
states. - For
FilterModifier
to conform toViewModifier
, you must implementbody(content:)
, where you apply the preferable transformations to thecontent
you receive as a parameter. - You animate the change in scale between
0.75
and1
. - Additionally, you make the view swing in a random direction and then get back to
0
degrees.
Now, add a new property in FilterView
to keep the transition created with your view modifier:
private let filterTransition = AnyTransition.modifier(
active: FilterModifier(active: true),
identity: FilterModifier(active: false)
)
To apply the newly created transition, add the following modifier to the Button
containing your filter item:
.transition(.asymmetric(
insertion: filterTransition,
removal: .scale
))
Typically, you apply the same transition to a view’s entry and exit. Since you only want the filter options to bounce when they appear, you need to apply an asymmetric transition. This way, you define insertion
and removal
transitions separately.
To start the transition, you need to animate the filterShown
value change.
Back inside ContentView.swift, find:
filterShown.toggle()
Replace it with:
withAnimation(filterShown
? .easeInOut
: .interpolatingSpring(stiffness: 20, damping: 3).speed(2.5)
) {
filterShown.toggle()
}
With this approach, you alternate between the plain .easeInOut
and the bouncy .interpolatingSpring
animations. Try it out. Run the app and tap the filter button.
Nice job! The filter view appears with a bouncy spring animation and disappears with an ease animation. How much cooler is that? Next, you’ll improve the user experience on the event details screen.
Improving UX With Collapsible Header
To connect your ContentView
to the new event details screen, wrap the EventView
instance inside the ForEach
with a NavigationLink
as follows:
NavigationLink(destination: EventDetailsView(event: event)) {
EventView(event: event)
}
Now, tapping on an event view cell in the container will navigate the user to the EventDetailsView
. Try it out. Run the app to see what you’ve got to work on next.
On the new details screen, you’ll see all the relevant information on the specific event: the date, location, tickets available and the team’s upcoming games. You’ll also notice a button, Seating Chart. It doesn’t do much right now, but soon it’ll be a linking point to the component you’ll craft in the fourth and fifth chapters. Sounds intriguing?
For now, you have a lot to do on this screen.
Although the event details screen looks fine and fulfills its designated purpose - displaying event info - a good animation can improve its usability drastically.
Notice that EventDetailsView
contains many components. Some are essential, while others are less critical. When a user scrolls down to see all the upcoming events, the most vital information and functionality gets lost: the date and button to navigate to the seating chart to buy tickets. If too many events are already planned, it can take a while to scroll back to the important section.
There are multiple viable approaches to solving this problem. You could split the screen’s functionality and, for example, show the upcoming games only on demand, thus making the details screen smaller and more manageable.
Alternatively, you could hide them completely, add a search bar on the events list screen and make users look for the stuff they need. You could also “pin” the crucial components to stay visible and accessible while a user scrolls down the screen’s content, which is the strategy you’ll take for this chapter.
Building a Collapsible Header With GeometryReader
To make a header view collapse with the same velocity as a user scrolls down content, you need to shrink it vertically by the value of the scroll view’s offset.
Since you’re now an expert on SwiftUI’s GeometryReader
, the first steps may already be clear to you: create a new SwiftUI view file and name it HeaderGeometryReader.swift. It’s responsible for catching the offset value of your scroll view.
Add these properties to the newly generated struct:
@Binding var offset: CGFloat
@Binding var collapsed: Bool
@State var startOffset: CGFloat = 0
EventDetailsView
is aware of the current offset and if the header is collapsed.
Before moving on, remove the generated HeaderGeometryReader_Previews
because you won’t need it for this specific view.
Then, replace body
’s content with:
GeometryReader<AnyView> { proxy in
// 1
guard proxy.frame(in: .global).minX >= 0 else {
return AnyView(EmptyView())
}
Task {
// 2
offset = proxy.frame(in: .global).minY - startOffset
withAnimation(.easeInOut) {
// 3
collapsed = offset < Constants.minHeaderOffset
}
}
return AnyView(Color.clear.frame(height: 0)
.task {
// 4
startOffset = proxy.frame(in: .global).minY
}
)
}
In the code snippet above, you:
- Verify that the frame of the proxy is valid as a safety measure. If you navigate to a different screen while some transitions are animating on the previous screen, the proxy’s values may be off upon entering the screen. You ignore such values until the valid ones appear.
- Calculate the change to the scroll view’s offset by subtracting the starting value from the current offset,
minY
of the proxy’s frame. This way, before a user interacts with the content, the value ofoffset
is0
. - The header should collapse if the offset gets below the minimum value. You wrap this change in
withAnimation
to allow seamless transitions between the collapsed and expanded states. - To fetch the starting value of the offset only once before the view appears, you attach a
.task
modifier and access the proxy’sminY
from within it.
Now, open EventDetailsView.swift and add the offset
and collapsed
properties:
@State private var offset: CGFloat = 0
@State private var collapsed = false
Next, wrap the content of yourScrollView
with a ZStack
, so it looks like so:
ScrollView {
ZStack {
VStack {...}
}
}
Note: You may find the code folding ribbon option particularly helpful while working on this chapter. With it, you can easily fold the code blocks when you need to wrap them into another component. To enable it, tap on the checkbox in Xcode Preferences -> Text Editing -> Display -> Code Folding Ribbon.
Then, add your HeaderGeometryReader
inside the ZStack
above the VStack
:
HeaderGeometryReader(
offset: $offset,
collapsed: $collapsed
)
Before EventDetailsView
‘s body
grows even further, create a new SwiftUI file and name it HeaderView.swift. This file is responsible for the views you’ll pin to the top of the screen.
Add the following properties inside the struct:
var event: Event
var offset: CGFloat
var collapsed: Bool
Next, replace the content of body
with:
ZStack {
// 1
AsyncImage(
url: event.team.sport.imageURL,
content: { image in
image.resizable()
.scaledToFill()
.frame(width: UIScreen.main.bounds.width)
// 2
.frame(height: max(
Constants.minHeaderHeight,
Constants.headerHeight + offset
))
.clipped()
},
placeholder: {
ProgressView().frame(height: Constants.headerHeight)
}
)
}
Here’s what’s happening:
- You add an
AsyncImage
fromEventDetailsView
toHeaderView
and wrap it into aZStack
. - You use the new
headerHeight
for the height instead of the hardcoded one. This makes the image shrink as the user scrolls the content ofEventDetailsView
and updates the height ofAsyncImage
in.frame
so that it changes alongside the offset but doesn’t go below the minimum allowed value.
Now, below clipped()
, add some shadow and round the corners of the image to make it appear elevated above the content:
.cornerRadius(collapsed ? 0 : Constants.cornersRadius)
.shadow(radius: 2)
Since you’re going to display the title and date label in the header, you need to apply an overlay to the image to darken it and make the text more readable. Add an .overlay
modifier to the end of AsyncImage
:
.overlay {
RoundedRectangle(cornerRadius: collapsed ? 0 : Constants.cornersRadius)
.fill(.black.opacity(collapsed ? 0.4 : 0.2))
}
Since you’re building a custom header, namely a toolbar, you need to hide the system header. Head over to EventDetailsView.swift and add the two following modifiers on the root ZStack
of EventDetailsView
:
.toolbar(.hidden)
.edgesIgnoringSafeArea(.top)
You also use edgesIgnoringSafeArea(_:)
to make the content of EventDetailsView
move toward the top border of the screen.
Now, it’s time to add the missing views in your HeaderView
. Head back to HeaderView.swift and add a VStack
below the AsyncImage
. It’ll be the container for the team’s name and date labels and the back button because you need to replace the system one, which is now gone:
VStack(alignment: .leading) {
}
.padding(.horizontal)
.frame(height: max(
Constants.minHeaderHeight,
Constants.headerHeight + offset
))
Next, you’ll want to dismiss the view once the user taps the back button. Add the following environment property to HeaderView
above the event
property:
@Environment(\.dismiss) var dismiss
Note: SwiftUI provides several environment values which can come in handy, like color scheme and size class. You can access them using a keypath in the
@Environment
attribute.
Next, add a new Button
inside the VStack
together with the back button and the title label:
Button {
// 1
dismiss()
} label: {
HStack {
Image(systemName: "chevron.left")
.resizable()
.scaledToFit()
.frame(height: Constants.iconSizeS)
.clipped()
.foregroundColor(.white)
// 2
if collapsed {
Text(event.team.name)
.frame(maxWidth: .infinity, alignment: .leading)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.white)
} else {
Spacer()
}
}
.frame(height: 36.0)
// 3
.padding(.top, UIApplication.safeAreaTopInset + Constants.spacingS)
}
Here’s a code breakdown:
- You wrap an
HStack
inside aButton
which triggers thedismiss()
method when tapped. - You show the team’s name alongside the back button image in case the header is collapsed. Otherwise, you replace it with the
Spacer
. - Since you make your header ignore the safe area insets via
edgesIgnoringSafeArea(_:)
, you need to account for this measurement yourself to prevent the notch of your iPhone from hiding the back button and title.
The only thing missing in the header is the date label. Add the following code right below the Button
you added in the previous step:
// 1
Spacer()
// 2
if collapsed {
HStack {
Image(systemName: "calendar")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(height: Constants.iconSizeS)
.foregroundColor(.white)
.clipped()
Text(event.date)
.foregroundColor(.white)
.font(.subheadline)
}
.padding(.leading, Constants.spacingM)
.padding(.bottom, Constants.spacingM)
}
Here’s what you did:
- You use a
Spacer
to keep the space when the header is expanded and prevent the back button from jumping along the y-axis between state changes. - If
collapsed
istrue
, you display a calendar icon and the date.
Finally, update HeaderView_Previews
’s Header
initializer:
HeaderView(
event: Event(team: teams[0], location: "Somewhere", ticketsLeft: 345),
offset: -100,
collapsed: true
)
Note: Check out the preview to see this view. Tweak the values of
HeaderView
in the preview to check its states.
Now, go back to EventDetailsView.swift and remove the AsyncImage
. In its place, add HeaderView
in the root ZStack
above ScrollView
:
HeaderView(
event: event,
offset: offset,
collapsed: collapsed
)
.zIndex(1)
A few lines down, add a Spacer
above the team name to prevent the header from overlapping the screen’s content:
Spacer()
.frame(height: Constants.headerHeight)
Updating EventLocationAndDate to Animate When Collapsed
Now, open EventLocationAndDate.swift and add the following property below the event
:
var collapsed: Bool
Update the preview with:
EventLocationAndDate(
event: makeEvent(for: teams[0]),
collapsed: false
)
Inside the second HStack
, wrap the calendar Image
and the date Text
into an if
statement to remove them when the header collapses, leaving the Spacer
outside of the condition:
if !collapsed {
...
}
Go back to EventDetailsView.swift and pass collapsed
to the initializer of the EventLocationAndDate
:
EventLocationAndDate(event: event, collapsed: collapsed)
Last but not least, hide the team’s name label once the header collapses as well:
if !collapsed {
Text(event.team.name)
.frame(maxWidth: .infinity, alignment: .leading)
.font(.title2)
.fontWeight(.black)
.foregroundColor(.primary)
.padding()
}
Run the app or check the preview of EventDetailsView
:
Synchronizing Geometry of Views With .matchedGeometryEffect
To make it appear as if you’re “pinning” a label when it reaches the top of the screen, you need to align its disappearance in EventDetailsView
and appearance in HeaderView
. However, those are two different labels existing in two separate components!
It can seem challenging to implement this, but SwiftUI offers an out-of-the-box solution for this very problem — matchedGeometryEffect
. This modifier matches two views by updating the frame of one view to match the frame of another.
SwiftUI recognizes which views it needs to adjust by their identifier in the common namespace.
Obtaining the animation namespace of a view is very straightforward. Simply add the following property to EventDetailsView
above the event
property:
@Namespace var namespace
Now that you have an animation namespace, you can add the .matchedGeometryEffect
modifier to the team’s name text below its padding:
.matchedGeometryEffect(
id: "title",
in: namespace,
properties: .position
)
Note: When linking two views with
.matchedGeometryEffect
, you can specify whether you want to align their sizes, positions or both. By default, it matches their frames, which works well for most use cases. When animating views containing text, you may want to use the.position
option to prevent the text from being truncated while transitioning.
To make the title transition above the header, set its zIndex
to move the label on top of the header:
.zIndex(2)
You need to share the same namespace ID between EventDetailsView
and HeaderView
to link their matched geometry together. Add a new property to HeaderView
below the dismiss
property:
var namespace: Namespace.ID
Then, add a matching .matchedGeometryEffect
to the team name Text
in HeaderView
:
.matchedGeometryEffect(
id: "title",
in: namespace,
properties: .position
)
To animate the transition for the calendar icon and the date label, add .matchedGeometryEffect
to them as well:
.matchedGeometryEffect(id: "icon", in: namespace)
.matchedGeometryEffect(
id: "date",
in: namespace,
properties: .position
)
To match them with the views inside EventLocationAndDate
, follow the same steps as for HeaderView
.
Go to EventLocationAndDate
and:
- Add
namespace
of typeNamespace.ID
. - Add
.matchedGeometryEffect
to the calendar icon, the date label with the"icon"
and"date"
identifiers, respectively:
.matchedGeometryEffect(id: "icon", in: namespace)
.matchedGeometryEffect(
id: "date",
in: namespace,
properties: .position
)
If you want to have a preview of a view requiring a namespace, for example, EventLocationAndDate
and HeaderView
, you’ll need to make @Namespace
static:
struct EventLocationAndDate_Previews: PreviewProvider {
@Namespace static var namespace
static var previews: some View {
EventLocationAndDate(
namespace: namespace,
event: makeEvent(for: teams[0]),
collapsed: false
)
}
}
Finally, pass namespace
from EventDetailsView
to both HeaderView
’s and EventLocationAndDate
’s initializers:
HeaderView(
namespace: namespace,
event: event,
offset: offset,
collapsed: collapsed
)
EventLocationAndDate(
namespace: namespace,
event: event,
collapsed: collapsed
)
Refresh the preview of EventDetailsView
or run the app.
You’ve got the transitions working, but the button still goes out of sight when you scroll down to the upcoming games list. To keep it accessible to the user at all times, you’ll implement the floating action button concept.
Implementing Floating Action Button
You’ll wrap up this chapter by adding a Floating Action Button to order tickets for the event, and transition to it as the user scrolls - so the user has the order button right at their finger tips, wherever they are.
Inside EventDetailsView
, wrap the button in an if
statement:
if !collapsed {
Button(action: {}) {
...
}
}
Then, add .matchedGeometryEffect
to the Text
inside the button’s label with a new identifier:
.matchedGeometryEffect(
id: "button",
in: namespace,
properties: .position
)
To make the button shrink smoothly while scrolling, add a constant .frame(width:)
to RoundedRectangle
:
.frame(
width: max( // 2
Constants.floatingButtonWidth,
min( // 1
UIScreen.halfWidth * 1.5,
UIScreen.halfWidth * 1.5 + offset * 2
)
)
)
- The
min
function returns the smaller value out of the two parameters it receives. This ensures that as theoffset
value grows, the button’s width doesn’t go overUIScreen.halfWidth * 1.5
or 75% of the screen width. - The
max
function does the exact opposite - it’s helpful to ensure the bottom limit of a value. Once theoffset
value grows negatively, it caps the minimum value of the button’s width to theConstants.floatingButtonWidth
.
This way, although the button’s width depends on the offset
, you limit its possible values to the range between the Constants.floatingButtonWidth
and 75% of the screen width.
Next, add a computed property inside EventDetailsView
to store the collapsed representation of the button:
var collapsedButton: some View {
HStack {
Spacer()
Button(action: { seatingChartVisible = true }) {
Image("seats")
.resizable()
.renderingMode(.template)
.scaledToFit()
.frame(width: 32, height: 32)
.foregroundColor(.white)
.padding(.horizontal)
.background {
RoundedRectangle(cornerRadius: 36)
.fill(Constants.orange)
.shadow(radius: 2)
.frame(width: Constants.floatingButtonWidth, height: 48)
}
.matchedGeometryEffect(
id: "button",
in: namespace,
properties: .position
)
}
.padding(36)
}
}
In the collapsed state, you replace the label with an icon. And obviously we won’t forget to link it to the original button with a .matchedGeometryEffect
of the same id
.
Finally, replace HeaderView
in EventDetailsView
’s body
with the code below:
VStack {
HeaderView(
namespace: namespace,
event: event,
offset: offset,
collapsed: collapsed
)
Spacer()
if collapsed {
collapsedButton
}
}
.zIndex(1)
Time to run the app one more time!
Key Points
- Transitions define the way a view appears and disappears from the screen.
- You can combine your transitions with
.combined(with:)
. - Use
.matchedGeometryEffect
to align one view’s frame to the other view’s frame into a seamless animation. - A view will have different insertion and removal animations if you specify both via
.asymmetric(with:)
. - To implement a custom transition, you need to create a
ViewModifier
with the different transformations applied for the active and identity states.