iosswiftanimationswiftuigeometryreader

Unexpected Behavior with SwiftUI Slide Transition Using Shared Offset


I'm experiencing some unexpected behavior with a slide transition in a SwiftUI view.

I have a series of slides within an HStack that I want to animate through when a button is pressed.

I expected that I'd need to calculate individual offsets for each slide to make them appear and disappear correctly.

However, when using the same offset calculation for each slide, the transition still behaves correctly, and I'm trying to understand why.

Here's the relevant part of my code:

struct OnboardingView: View {
    @StateObject private var viewModel = OnboardingViewModel()

    func calculateOffset(_ index: Int, _ screenWidth: CGFloat) -> CGFloat {
        let difference = index - viewModel.currentPage
        return screenWidth * CGFloat(difference)
    }

    var body: some View {
        GeometryReader { geometry in
            let screenWidth = geometry.frame(in: .global).size.width

            HStack(spacing: 0) {
                SlideView1()
                    .frame(width: screenWidth)
                    .offset(x: calculateOffset(1, screenWidth))
                SlideView2()
                    .frame(width: screenWidth)
                    .offset(x: calculateOffset(1, screenWidth))
                SlideView3()
                    .frame(width: screenWidth)
                    .offset(x: calculateOffset(1, screenWidth))
            }
        }
        .background(Color.blue.ignoresSafeArea())
        .safeAreaInset(edge: .bottom) {
            VStack {
                Text("Current Page: \(viewModel.currentPage)")
                Button("Next") {
                    withAnimation {
                        viewModel.currentPage += 1
                    }
                }
            }
        }
    }
}

class OnboardingViewModel: ObservableObject {
    @Published var currentPage = 1
}

struct SlideView1: View {
    var body: some View {
        Text("Slide 1")
    }
}

struct SlideView2: View {
    var body: some View {
        Text("Slide 2")
    }
}

struct SlideView3: View {
    var body: some View {
        Text("Slide 3")
    }
}


class OnboardingViewModel: ObservableObject {
    @Published var currentPage = 1
}

As you can see, calculateOffset(1, width) is used for all slides, yet the view transitions correctly with each button press.

To provide more context, here's an image from the Xcode view hierarchy debugger showing all the slides lined up side by side, which seems to confirm that the layout is as expected for an HStack:

View Hierarchy Debugger Snapshot

I'm puzzled as to why this works. My expectation was that each slide should have its own offset calculation based on its position in the stack.

Could someone help me understand the underlying mechanics of why the shared offset leads to the correct behavior?


Solution

  • First of all


    Let's make it a lot more readable and viewable.

    let screenWidth = geometry.frame(in: .global).size.width
    let screenHeight = geometry.frame(in: .global).size.height
    HStack(spacing: 0) {
        SlideView1()
            .frame(width: screenWidth, height: screenHeight)
            .offset(x: calculateOffset(1, screenWidth))
        SlideView2()
            .frame(width: screenWidth, height: screenHeight)
            .offset(x: calculateOffset(1, screenWidth))
        SlideView3()
            .frame(width: screenWidth, height: screenHeight)
            .offset(x: calculateOffset(1, screenWidth))
    }
    

    we can have a check in Debug View Hierarchy and that seems to be like that

    enter image description here

    Then


    we can adjust the code a little bit

    HStack(spacing: 0) {
        SlideView1()
            .frame(width: screenWidth, height: screenHeight)
        SlideView2()
            .frame(width: screenWidth, height: screenHeight)
        SlideView3()
            .frame(width: screenWidth, height: screenHeight)
    }
    .offset(x: calculateOffset(1, screenWidth))
    
    

    If we have a check for Debug View Hierarchy again, you can find it totally the same.

    Every time you click the button, the HStack's position is changed

    Conclusion


    Your are just adjusting the HStack's position not the SlideView's.

    What's more


    I think your func calculateOffset is not the same you want, you can change it to.

    func calculateOffset(_ index: Int, _ screenWidth: CGFloat) -> CGFloat {
        let difference = 1 - index
        return screenWidth * CGFloat(difference)
    }
    

    and make it effect by

    .offset(x: calculateOffset(viewModel.currentPage, screenWidth))