iosswiftanimationswiftui

How to achieve animation similar to App store's feature view


enter image description hereI am using following code, This works perfectly fine but its full screen, however in case of App Store animation, its not full screen view. I have tried popover and custom view as well but didnt work.

struct ContentView: View {
    let icons = [
        Icon(id: "figure.badminton", color: .red),
        Icon(id: "figure.fencing", color: .orange),
        Icon(id: "figure.gymnastics", color: .green),
        Icon(id: "figure.indoor.cycle", color: .blue),
        Icon(id: "figure.outdoor.cycle", color: .purple),
        Icon(id: "figure.rower", color: .indigo),
    ]

    @Namespace var animation
    @State private var selected: Icon?

    var body: some View {
        ZStack {
            LazyVGrid(columns: [.init(.adaptive(minimum: 100, maximum: 300))]) {
                ForEach(icons) { icon in
                    Button {
                        selected = icon
                    } label: {
                        Image(systemName: icon.id)
                    }
                    .foregroundStyle(icon.color.gradient)
                    .font(.system(size: 100))
                    .background(Color.yellow.opacity(0.5))
                    .matchedTransitionSource(id: icon.id, in: animation)
                }
            }
            .sheet(item: $selected, content: { icon in
                VStack(content: {
                    DestinationView(icon: icon, animation: animation)
                        .background(Color.yellow.opacity(0.5))
                        .presentationDetents([.medium])
                })
            })
            .background(Color.yellow.opacity(0.5))
        }
        .background(.yellow)
        .ignoresSafeArea(.all)
       
    }
}

The view which will be presented is

struct DestinationView: View {
    var icon: Icon
    var animation: Namespace.ID
   var body: some View {
        Image(systemName: icon.id)
            .font(.system(size: 200))
            .frame(width: 250, height: 250)
            .foregroundStyle(icon.color.gradient)
            .background(Color.yellow.opacity(0.5))
            .navigationTransition(.zoom(sourceID: icon.id, in: animation))
    }
}

Solution

  • Instead of using a sheet, you could consider showing the selected view as the top layer of the ZStack and using .matchedGeometryEffect for the animation. This might give you more control over the appearance and animation.

    The technique is illustrated in the second part of this answer (it was my answer). To adapt it to your case:

    By using .matchedGeometryEffect to determine only the position for DestinationView, not its size, the fixed font size that DestinationView is using does not cause animation issues. This should also work better if the view contains some text too, because the size of the destination view doesn't change during animation (it is scaled instead).

    struct ContentView: View {
        let icons = [
            Icon(id: "figure.badminton", color: .red),
            Icon(id: "figure.fencing", color: .orange),
            Icon(id: "figure.gymnastics", color: .green),
            Icon(id: "figure.indoor.cycle", color: .blue),
            Icon(id: "figure.outdoor.cycle", color: .purple),
            Icon(id: "figure.rower", color: .indigo),
        ]
    
        @Namespace private var animation
        @State private var selected: Icon?
        @State private var isShowingSelection = false
        let containerId = "container"
    
        var body: some View {
            ZStack {
                LazyVGrid(columns: [.init(.adaptive(minimum: 100, maximum: 300))]) {
                    ForEach(icons) { icon in
                        Button {
                            selected = icon
                        } label: {
                            Image(systemName: icon.id)
                        }
                        .foregroundStyle(icon.color.gradient)
                        .font(.system(size: 100))
                        .matchedGeometryEffect(id: icon.id, in: animation)
                    }
                }
                if let icon = selected {
    
                    // Add a dimming layer and use to intercept taps
                    Color.black
                        .opacity(isShowingSelection ? 0.25 : 0)
                        .animation(.easeInOut(duration: 0.2), value: isShowingSelection)
                        .onTapGesture {
                            withAnimation(.easeInOut) {
                                isShowingSelection = false
                            } completion: {
                                selected = nil
                            }
                        }
                        .ignoresSafeArea()
    
                    // Show the selected view
                    DestinationView(icon: icon)
                        .compositingGroup()
                        .shadow(radius: isShowingSelection ? 10 : 0)
                        .allowsHitTesting(false)
                        .transition(.opacity.animation(.easeInOut(duration: 0.2)))
                        .scaleEffect(isShowingSelection ? 1 : 0.5)
                        .matchedGeometryEffect(
                            id: isShowingSelection ? containerId : icon.id,
                            in: animation,
                            properties: .position,
                            isSource: false
                        )
                        .onAppear {
                            withAnimation(.bouncy(extraBounce: 0.1)) {
                                isShowingSelection = true
                            }
                        }
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .matchedGeometryEffect(id: containerId, in: animation)
            .background(.yellow.opacity(0.5))
        }
    }
    
    struct DestinationView: View {
        let icon: Icon
        var body: some View {
            Image(systemName: icon.id)
                .font(.system(size: 200))
                .foregroundStyle(icon.color.gradient)
                .padding()
                .background(.yellow, in: .rect(cornerRadius: 20))
        }
    }
    
    struct Icon: Identifiable {
        let id: String
        let color: Color
    }
    

    Animation