Creating seamless transitions between views

Creating seamless transitions between views

The Github repository with source code can be found here.

Create the Item Model

Start by creating the item model that represents each item on the list, including simple properties such as a title, color, and description.

struct Item: Hashable {
    let id = UUID()
    let title: String
    let color: Color
    let description: String
}


Set up the Initial View

Next, let's create the initial view, which displays a list of items. Each item on the list will have a button that triggers the transition to a detailed view.

import SwiftUI

struct ContentView: View {
    
    var body: some View {
        VStack {
            ListView()
        }
    }
}

struct ListView: View {
    let items = [
        Item(title: "STRATEGY", color: Color("Purple"), description: "Solving problems and crafting solutions that make your business grow. \n\nWe make digital products, not just software, so we begin every project with Product Strategy Sprints to define problems, visualize solutions, and scope an Agile development plan."),
        Item(title: "DESIGN", color: Color("Red"), description: "Engaging users with beautiful design \n\nIn a crowded market, beautiful designs communicate to users how seriously you take their attention. In tandem with our product strategists, product designers work tirelessly to inspire users with original branding, human-centric experience design, and dynamic animations."),
        Item(title: "ENGINEERING", color: Color("MidBlue"), description: "Scaling your business with enterprise-class code\n\nThe Studio engineering teams translate our product designs into functional code on web, iOS, and Android. Whether you need a responsive web app or an iPhone application, our full-stack team will deliver documented, extensible code."),
        Item(title: "GROWTH", color: Color("DarkBlue"), description: "We think about growth as a diverse toolkit – not an individual channel.\n\nWe create and execute strategies to scale your company to the next level and define clear & measurable growth targets to keep team members aligned on the metrics that matter.")
    ]
    @State private var selectedItem: Item?

    var body: some View {
        VStack {
            ScrollView {
                ForEach(items, id: \.self) { item in
                    Button(action: {
                        withAnimation(.easeInOut) {
                            selectedItem = item
                        }
                    }) {
                        Text(item.title)
                            .matchedGeometryEffect(id: item.title, in: namespace, properties: .position)
                            .font(.title)
                            .foregroundColor(.white)
                            .frame(maxWidth: .infinity, minHeight: 180)
                            .background(
                                RoundedRectangle(cornerRadius: 8)
                                .fill(item.color)
                                .matchedGeometryEffect(id: item.color, in: namespace)
                            )
                            .padding(.horizontal)
                    }
                }
            }
        }
    }
}

Create the Detail View

Now, let's create the DetailView that displays the details of a selected item.

struct DetailView: View {
    let item: Item

    var body: some View {
        VStack(spacing: 20) {
            RoundedRectangle(cornerRadius: 15)
                .fill(item.color)
                .padding()
                .overlay(
                    Image("Studio Logo")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(height: 65)
                )
            
            Text(item.title)
                .font(.system(size: 50))
                .padding()
            
            Text(item.description)
                .font(.body)
                .padding()
        }
        .cornerRadius(16)
    }
}

Add the Transition

Now that we have everything setup, we can implement the transition. To do this, we first need to add a @Namespace which is simply a view property that links views together.

struct ListView: View {
        ...
    @Namespace private var namespace
    @State private var selectedItem: Item?

    var body: some View {
        ...
    }

In the detail view, you need to add Namespace.ID which is a reference to the Namespace created in the parent view.

struct DetailView: View {
    let item: Item
    let namespace: Namespace.ID

    var body: some View {
        ...
    }

Once that’s done, add this modifier to all the views that should be part of the transition:

func matchedGeometryEffect<ID>(id: ID, in namespace: Namespace.ID, properties: MatchedGeometryProperties = .frame, anchor: UnitPoint = .center, isSource: Bool = true) -> some View where ID : Hashable

The identifier should be unique allowing SwiftUI to transition between one layout and another.

One important thing to remember is to place all the modifiers that we want to include in the transition before the .matchedGeometryEffect(id: identifier, in: namespace)modifier. It is generally recommended to set the frame and padding modifiers after the matched geometry effect to ensure the desired behavior.

Apple does not provide detailed explanations for this, but it might be related to the way SwiftUI calculates positions and handles transitions.

By following this guideline, we can avoid any unexpected or unintended behavior during the animation.

struct ListView: View {
    let items = [
        Item(title: "STRATEGY", color: Color("Purple"), description: "Solving problems and crafting solutions that make your business grow. \n\nWe make digital products, not just software, so we begin every project with Product Strategy Sprints to define problems, visualize solutions, and scope an Agile development plan."),
        Item(title: "DESIGN", color: Color("Red"), description: "Engaging users with beautiful design \n\nIn a crowded market, beautiful designs communicate to users how seriously you take their attention. In tandem with our product strategists, product designers work tirelessly to inspire users with original branding, human-centric experience design, and dynamic animations."),
        Item(title: "ENGINEERING", color: Color("MidBlue"), description: "Scaling your business with enterprise-class code\n\nThe Studio engineering teams translate our product designs into functional code on web, iOS, and Android. Whether you need a responsive web app or an iPhone application, our full-stack team will deliver documented, extensible code."),
        Item(title: "GROWTH", color: Color("DarkBlue"), description: "We think about growth as a diverse toolkit – not an individual channel.\n\nWe create and execute strategies to scale your company to the next level and define clear & measurable growth targets to keep team members aligned on the metrics that matter.")
    ]
    @Namespace private var namespace
    @State private var selectedItem: Item?
    
    var body: some View {
        VStack {
            ScrollView {
                ForEach(items, id: \.self) { item in
                    Button(action: {
                        withAnimation(.easeInOut) {
                            selectedItem = item
                        }
                    }) {
                        Text(item.title)
                            .matchedGeometryEffect(id: item.title, in: namespace, properties: .position)
                            .font(.title)
                            .foregroundColor(.white)
                            .frame(maxWidth: .infinity, minHeight: 180)
                            .background(
                                RoundedRectangle(cornerRadius: 8)
                                .fill(item.color)
                                .matchedGeometryEffect(id: item.color, in: namespace)
                            )
                            .padding(.horizontal)
                    }
                }
            }
        }.overlay {
            if let selectedItem {
                DetailView(item: selectedItem, namespace: namespace)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color.white)
                    .onTapGesture {
                        withAnimation(.spring()) {
                            self.selectedItem = nil
                        }
                    }
            }
        }
    }
}

struct DetailView: View {
    let item: Item
    let namespace: Namespace.ID

    var body: some View {
        VStack(spacing: 20) {
            RoundedRectangle(cornerRadius: 15)
                .fill(item.color)
                .matchedGeometryEffect(id: item.color, in: namespace)
                .padding()
                .overlay(
                    Image("Studio Logo")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(height: 65)
                )
            
            Text(item.title)
                .matchedGeometryEffect(id: item.title, in: namespace, properties: .position)
                .font(.system(size: 50))
                .padding()
            
            Text(item.description)
                .font(.body)
                .padding()
        }
        .cornerRadius(16)
    }
}

struct DetailView_Previews: PreviewProvider {
    @Namespace static var namespace

    static var previews: some View {
        DetailView(item: Item(title: "STRATEGY", color: Color("Purple"), description: "Solving problems and crafting solutions that make your business grow. \n\nWe make digital products, not just software, so we begin every project with Product Strategy Sprints to define problems, visualize solutions, and scope an Agile development plan."), namespace: namespace)
    }
}

You might have noticed that we are using .matchedGeometryEffect(...properties:.position). We tell SwiftUI to copy the position from the source view so as to prevent the text from being truncated during the animation.

Once an item is selected, we hide the list to display its detail view. SwiftUI will now make a spring animation as defined in the onTapGesture.


Swipe to dismiss

For a nicer user experience, we will add a swipe to dismiss gesture to go back to the list of items.

To make this reusable, we can create a ViewModifier that can be applied to any view.

struct SwipeToDismissModifier: ViewModifier {

    @State private var offset: CGSize = .zero
    var onDismiss: () -> Void

    func body(content: Content) -> some View {
        content
            .offset(y: offset.height)
            .animation(.interactiveSpring(), value: offset)
            .simultaneousGesture(
                DragGesture()
                    .onChanged { gesture in
                        offset = gesture.translation
                    }
                    .onEnded { _ in
                        if abs(offset.height) > 100 {
                            onDismiss()
                        } else {
                            offset = .zero
                        }
                    }
            )
    }
}


The modifier has the following properties:

offset: A state property that represents the current offset of the view. It starts with zero size and gets updated as the user interacts with the view.

onDismiss: A closure that will be called when the view is dismissed.

The content view is offset vertically by the offset.height value, which creates the visual effect of moving the view based on the user's gesture.

An animation is applied to the offset property to provide a smooth interactive experience. The animation is triggered whenever the offset value changes.

The simultaneousGesture modifier is used to attach a DragGesture to the content view. This gesture allows the user to interact with the view by dragging it.

When dragged, the view updates the offset property with the translation of the gesture, allowing the view to follow the user's finger. When the user stops dragging, onEnded is called and checks if the value of the vertical offset (offset.height) exceeds a threshold value (100 in this case). If it does, the onDismiss closure is called to perform the dismissal action. Otherwise, the offset is reset to zero, bringing the view back to its original position.

We then create an extension to use it in an elegant way:

extension View {
    func swipeToDismiss(onDismiss: @escaping () -> Void) -> some View {
        modifier(SwipeToDismissModifier(onDismiss: onDismiss))
    }
}

Finally, apply the modifier to the DetailView.

DetailView(item: selectedItem, namespace: namespace)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(Color.white)
    .swipeToDismiss {
        withAnimation(.spring()) {
            self.selectedItem = nil
        }
    }

Layout transition

Expandable header

To demonstrate another way to use matchedGeometryEffect, let's add an expandable header to our list.

We simply create 2 layouts - one horizontal and one vertical - and use matchedGeometryEffect on the icon and the title for the expanded and retracted view so SwiftUI knows how to transition between the two view states.

var retractedHeader: some View {
        HStack {
            Image(systemName: "star.circle")
                .font(.system(size: 50))
                .matchedGeometryEffect(id: "icon", in: namespace)
            
            VStack(alignment: .leading) {
                Text("Item list")
                    .font(.system(size: 30))
                    .matchedGeometryEffect(id: "title", in: namespace)
                
                Text("Tap to read more")
                    .font(.system(size: 20))
            }
            
            Spacer()
        }
        .onTapGesture {
            withAnimation(.easeOut) {
                isHeaderExtended.toggle()
            }
        }
        .padding()
    }
    
    var extendedHeader: some View {
        VStack {
            Image(systemName: "star.circle")
                .font(.system(size: 100))
                .matchedGeometryEffect(id: "icon", in: namespace)
            
            Text("Item list")
                .font(.system(size: 40))
                .matchedGeometryEffect(id: "title", in: namespace)
            
            Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce non nibh varius odio auctor blandit. Quisque sollicitudin massa justo. Fusce consequat erat ac quam lobortis finibus. Pellentesque lorem lacus, mattis id aliquet a, dapibus et lorem. Integer ultrices pellentesque purus, non iaculis nisi consequat eu.")
                .font(.system(size: 15))
            
        }
        .onTapGesture {
            withAnimation(.easeIn) {
                isHeaderExtended.toggle()
            }
        }
        .padding()
    }
struct ListView: View {
    //...
    @State private var isHeaderExpended: Bool = false

    var body: some View {
    //...
        if isHeaderExpended {
            extendedHeader
        } else {
            retractedHeader
        }
        ScrollView {
            //...
        }
    }
}

Grid layout

Similarly, we can add a button to switch from a vertical layout to a grid layout.


@State private var isGridLayout: Bool = false

var body: some View {
    //Header

    VStack {
        ScrollView {
            if isGridLayout {
                gridLayout
            } else {
                // Vertical Layout
            }
        }
    }
    .overlay {
        VStack {
            HStack {
                Spacer()
                Button(action: {
                    withAnimation {
                        isGridLayout.toggle()
                    }
                }, label: {
                    Image(systemName: isGridLayout ? "rectangle.grid.1x2.fill" : "square.grid.2x2.fill")
                            .resizable()
                            .foregroundColor(.black)
                            .frame(width: 30, height: 30)
                })
            }
            .padding(.top)
            .padding(.trailing)
            Spacer()
        }
    }
}

private var gridLayout: some View {
        LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())]) {
            ForEach(items, id: \.self) { item in
                Button(action: {
                    withAnimation(.easeInOut) {
                        selectedItem = item
                    }
                }) {
                    Text(item.title)
                        .matchedGeometryEffect(id: item.title, in: namespace, properties: .position)
                        .font(.title2)
                        .foregroundColor(.white)
                        .frame(maxWidth: 300, minHeight: 330)
                        .background(
                            RoundedRectangle(cornerRadius: 8)
                            .fill(item.color)
                            .matchedGeometryEffect(id: item.color, in: namespace)
                        )
                }
            }
        }
        .padding(.horizontal)
    }

The code is pretty straight forward. A simple boolean is used to change between the grid and each layout.

To ensure  the button remains visible above the expandable header, overlay the button on the content VStack.

The same item, with the same matchedGeometryEffect, is used except we change its dimensions to fit two items per row.


Conclusion

Animations are a great way to bring life and smoothness to iOS apps.

Using @Namespace property wrapper to link views together and create a smooth animation when transitioning between them. By applying the matchedGeometryEffect modifier, we ensured that the transitioning views maintained their position, color, and other properties, resulting in a seamless user experience. Additionally, we implemented a swipe-to-dismiss gesture using a custom ViewModifier to enhance the interaction and allow users to easily navigate back to the previous screen. Furthermore, we demonstrated the versatility of matchedGeometryEffect by incorporating an expandable header in the list view, showcasing another way to leverage this powerful feature. With these techniques, you can create visually appealing and interactive interfaces in your SwiftUI applications.


Subscribe to Studio Bytes, your weekly blast of all things tech.

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Knowledge.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.