Search code examples
iosswiftui

Any way to set the view ID to after animation transition?


In SwiftUI I have this sequence I'd like a specific way where I swipe up in frame 1 into frame 2, then swipe left into frame 3. This works fine, as intended.

I did this by changing the views so essentially there is two views and they both share frame 2. So I have frame 2a & 2b are essentially the same but live in different Views. That way I can transition from vertical scrollview to horizontal scrollview.

This works as-stated, but the transition is a bit jarring because it goes halfway to 2a in firstView() and abruptly switches to 2b in secondView().

I think what I want is for some way to check for ScrollTransitionPhase.isIdentity before setting the View.id ?

I'm new to Swift in general and this is my first few attempts at SwiftUI iOS.

struct firstView: View {
    @Binding var scrollId: Int?
    var body: some View {
        ScrollView(.vertical) {
            VStack(spacing: 0) {
                Rectangle()
                    .fill(.gray)
                    .overlay
                {
                    Text("Frame 1 ")
                        .foregroundStyle(.white)
                }
                .padding(10)
                .containerRelativeFrame([.horizontal, .vertical])
                .scrollTransition { content, phase in
                    content.opacity(phase.isIdentity ? 1: 0)
                }
                .id(1)
                Rectangle()
                    .fill(.gray)
                    .overlay
                {
                    Text("Frame 2a ")
                        .foregroundStyle(.white)
                }
                .padding(10)
                .containerRelativeFrame([.horizontal, .vertical])
                .scrollTransition { content, phase in
                    content.opacity(phase.isIdentity ? 1: 0)
                }
                .id(2)
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.paging)
        .scrollPosition(id: $scrollId)
        Text("Hello World!")
    }
}

struct secondView: View {
    @Binding var scrollId: Int?
    var body: some View {
        ScrollView(.horizontal) {
            HStack(spacing: 0) {
                Rectangle()
                    .fill(.gray)
                    .overlay
                {
                    Text("Frame 2b ")
                        .foregroundStyle(.white)
                }
                .padding(10)
                .containerRelativeFrame([.horizontal, .vertical])
                .scrollTransition { content, phase in
                    content.opacity(phase.isIdentity ? 1: 0)
                }
                .id(3)
                Rectangle()
                    .fill(.gray)
                    .overlay
                {
                    Text("Frame 3 ")
                        .foregroundStyle(.white)
                }
                .padding(10)
                .containerRelativeFrame([.horizontal, .vertical])
                .scrollTransition { content, phase in
                    content.opacity(phase.isIdentity ? 1: 0)
                }
                .id(4)
                
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.paging)
        Text("Do you want to play a game?")
    }
}

struct lastView: View {
    var body: some View {
        Text("Now is the time for all good men")
    }
}

struct TestSwiftUIView4: View {
    @State var scrollId: Int?
    var body: some View {
        switch scrollId {
        case 4:
            lastView()
        case 2:
            secondView(scrollId: $scrollId)
        default:
            firstView(scrollId: $scrollId)
        }
    }
}

Solution

  • In firstView, the modifier scrollPosition has been used to bind the scroll position to the variable scrollId. This means, when the ScrollView in firstView is scrolled up, the state variable scrollId gets updated.

    The state variable probably gets updated half-way through the transition. However, when this happens, the parent view TestSwiftUIView4 replaces firstView with secondView. So this is why an abrupt change is happening.

    You can soften the transition a bit by adding an .animation modifier to the parent view. This requires nesting the switch statement inside a Group:

    var body: some View {
        Group {
            switch scrollId {
            case 4:
                lastView()
            case 2:
                secondView(scrollId: $scrollId)
            default:
                firstView(scrollId: $scrollId)
            }
        }
        .animation(.easeInOut, value: scrollId)
    }
    

    However, I would suggest, this whole view construction could be simplified:

    • Instead of switching views inside the parent view, just have one ScrollView as the parent container for the child views.
    • This way, there is no need for two versions of Frame 2. However, it requires that the text message that is shown below the scrolled content is moved into the parent view.
    • To prevent the user from scrolling back to Frame 1, scrolling can be disabled once Frame 1 has been scrolled away.
    • A binding to scrollId does not need to be passed to secondView, because it is not being updated. Indeed, frames 2 and 3 no longer need specific ids, because the ids are never referred to.
    • I couldn't see how lastView could ever be reached, I guess it was just a dummy view that was never intended to be shown?

    The only tricky bit is to disable scrolling on the vertical ScrollView when Frame 1 is scrolled up. I began by trying this:

    ScrollView(.vertical) {
        // ...
    }
    .scrollDisabled(scrollId == 2)
    

    This works, but it sometimes gives an abrupt transition like you had before, depending on how fast Frame 1 is scrolled up.

    A better solution is to use a separate state variable to control the disabled scrolling. The state variable can be updated when it is detected that secondView has been scrolled up to the top. A GeometryReader in the background of secondView can be used to determine this.

    In fact, once you have a flag that is updated when secondView is scrolled up, there is no need to track scrollId at all. This means the .scrollPosition can be dropped too, along with the other .id modifiers.

    Here is the fully revised version:

    struct firstView: View {
        var body: some View {
            Color.gray
                .overlay { Text("Frame 1") }
                .padding(10)
                .containerRelativeFrame([.horizontal, .vertical])
        }
    }
    
    struct secondView: View {
        var body: some View {
            ScrollView(.horizontal) {
                HStack(spacing: 0) {
                    Color.gray
                        .overlay { Text("Frame 2") }
                        .padding(10)
                        .containerRelativeFrame([.horizontal, .vertical])
                        .scrollTransition { content, phase in
                            content.opacity(phase.isIdentity ? 1: 0)
                        }
                    Color.gray
                        .overlay { Text("Frame 3") }
                        .padding(10)
                        .containerRelativeFrame([.horizontal, .vertical])
                        .scrollTransition { content, phase in
                            content.opacity(phase.isIdentity ? 1: 0)
                        }
                }
                .scrollTargetLayout()
            }
            .scrollTargetBehavior(.paging)
        }
    }
    
    struct TestSwiftUIView4: View {
        @State private var isSecondViewShowing = false
    
        private var scrollDetector: some View {
            GeometryReader { proxy in
                let minY = proxy.frame(in: .scrollView).minY
                Color.clear
                    .onChange(of: minY) { oldVal, newVal in
                        if !isSecondViewShowing, newVal <= 0 {
                            isSecondViewShowing = true
                        }
                    }
            }
        }
    
        var body: some View {
            VStack {
                ScrollView(.vertical) {
                    VStack(spacing: 0) {
                        firstView()
                            .scrollTransition { content, phase in
                                content.opacity(phase.isIdentity ? 1: 0)
                            }
                        secondView()
                            .scrollTransition { content, phase in
                                content.opacity(phase.isIdentity ? 1: 0)
                            }
                            .background { scrollDetector }
                    }
                    .foregroundStyle(.white)
                    .scrollTargetLayout()
                }
                .scrollIndicators(.hidden)
                .scrollTargetBehavior(.paging)
                .scrollDisabled(isSecondViewShowing)
    
                if isSecondViewShowing {
                    Text("Do you want to play a game?")
                } else {
                    Text("Hello World!")
                }
            }
        }
    }
    

    Animation