swiftui

SwiftUI selectable stepper in view that presents modally


Note: I'd like the solution to work for iOS 15 as well.

With the following implementation, tapping on the stepper from iPhone (iOS 15.8 (physical device) as well as iOS 17.2 (simulator and canvas)) presents ModalView, instead of changing the stepper's value as one would expect.

It's a somewhat real-life example but still basic, as I felt that having a view with just a stepper would have made the problem unrealistically easy.

struct CategoryView: View {
    @State private var modalIsPresented = false
    @State private var stepperValue = 0
    
    var body: some View {
        List {
            StepperRow(value: self.$stepperValue)
                .onTapGesture {
                    modalIsPresented = true
                }
        }
        .sheet(isPresented: $modalIsPresented) {
            modalIsPresented = false
        } content: {
            ModalView()
        }
    }
}

struct StepperRow: View {
    @Binding var value: Int
           
    var body: some View {
        VStack(alignment: .leading) {
            Stepper(
                "\(value) Name of the article",
                value: $value,
                in: 0...Int.max
            )
            Text("Item description, which could be long and I'd like to go under the stepper.")
                .font(.caption)
        }
    }
}

What doesn't work: setting the stepper's style to .plain or BorderlessButtonStyle(), as might work for a button.

The following code is a working solution, though it's ugly.

struct CategoryView: View {
    @State private var stepperValue = 0
    
    var body: some View {
        List {
            StepperRow(value: self.$stepperValue)
        }
    }
}

struct StepperRow: View {
    @Binding var value: Int
    @State private var modalIsPresented = false
           
    var body: some View {
        ZStack(alignment: .leading) {
            VStack(alignment: .leading) {
                HStack {
                    Text("\(value) Name of the article")
                    Spacer()
                    Stepper(
                        "",
                        value: $value,
                        in: 0...Int.max
                    )
                    .labelsHidden()
                    .hidden()
                }
                Text("Item description, which could be long and I'd like to go under the stepper.")
                    .font(.caption)
            }
            .onTapGesture {
                modalIsPresented = true
            }
            VStack(alignment: .leading) {
                HStack {
                    Text("\(value) Name of the article")
                        .hidden()
                    Spacer()
                    Stepper(
                        "",
                        value: $value,
                        in: 0...Int.max
                    )
                    .labelsHidden()
                }
                Text("Item description, which could be long and I'd like to go under the stepper.")
                    .font(.caption)
                    .hidden()
            }
        }
        .sheet(isPresented: $modalIsPresented) {
            modalIsPresented = false
        } content: {
            ModalView()
        }
    }
}

Basically I've put the stepper above the view to which I've added the onTapGesture recognizer, but to do so I had to duplicate the view code, so that everything laid out correctly, and hide the appropriate subviews, so that VoiceOver would ignore the duplicates, and also because it felt right.

Can anyone come up with a better solution?


Solution

  • Here is a working solution:

    struct ContentView: View {
        @State var stepperValue: Int = 0
        @State private var modalIsPresented = false
        
        var body: some View {
            List([1], id: \.self) { item in
                Button(action: presentModal) {
                    Stepper("\(stepperValue)", value: $stepperValue)
                }
            }
            .sheet(isPresented: $modalIsPresented) {
                modalIsPresented = false
            } content: {
                ContentView()
            }
        }
        
        func presentModal() {
            modalIsPresented = true
        }
    }
    

    Basically you can just use buttons as rows for lists, which I learned from this short video: https://www.youtube.com/watch?v=w4q5e6qs0cs&themeRefresh=1.

    You can then display whatever view you'd like in the button, a stepper in my case.

    And everything is working fine: if you tap on the stepper, you change its value; if you tap on the button, you trigger its action.