Search code examples
iosswiftswiftuipicker

Segmented picker in iOS - handle tap on already selected item


I use segmented picker in iOS which contains few items. When user tap on not selected item this item becomes selected. Some of items can contain sub items. So when user tap on already selected type I need to show modal window with subitems for choosing one of them. But taps on already selected items of segmented picker are not handling. I tried to use "long press" but it doesn't work as well.

I would like to use native iOS design that's why I don't want use "buttons" instead segmented picker.

So the question is how I can handle tap on already selected item of segmented picker for showing sub items to choosing one of them? It can be "long press" or other alternative which will be intuitive for user.

import SwiftUI

struct CustomSegmentedPicker: View {
    
    @State private var showModalSelectD: Bool = false
    
    enum periods {
        case A, B, C, D, All
    }
    
    @State var predefinedPeriod: periods = periods.All
    @State var predefinedPeriodD: String = "D1"
    
    var body: some View {
        ZStack {
            Color.clear
                .sheet(isPresented: $showModalSelectD, content: {
                    List {
                        Picker("D", selection: $predefinedPeriodD) {
                            Text("D1").tag("D1")
                            Text("D2").tag("D2")
                            Text("D3").tag("D3")
                        }
                        .pickerStyle(.inline)
                    }
                })
            VStack {
                HStack {
                    Picker("Please choose a currency", selection: $predefinedPeriod) {
                        Text("A").tag(periods.A)
                        Text("B").tag(periods.B)
                        Text("C").tag(periods.C)
                        Text("D (\(predefinedPeriodD))").tag(periods.D)
                            .contentShape(Rectangle())
                            .simultaneousGesture(LongPressGesture().onEnded { _ in
                                print("Got Long Press")
                                showModalSelectD.toggle()
                            })
                            .simultaneousGesture(TapGesture().onEnded{
                                print("Got Tap")
                                showModalSelectD.toggle()
                            })
                        Text("All").tag(periods.All)
                    }
                    .pickerStyle(SegmentedPickerStyle())
                }
            }
        }
    }
}

Solution

  • You could take advantage of the fact that the buttons of a segmented picker all have equal widths. An overlay can be used to cover just the picker item that is currently active. The overlay can then intercept further tap gestures on the same item. If a different item is selected, the overlay moves to that one instead, etc.

    • If the overlay is given an opacity of 0.001 then it is effectively invisible, but it is still able to receive gestures.

    • Since the picker items all have equal widths, the width of a single item can be computed easily from the total width, which can be measured using a GeometryReader. The overlay could then be moved into position by applying an x-offset. The offset needs to be be a multiple of the item width, dependent on the index of the current selection.

    • Another way to perform positioning would be to use .matchedGeometryEffect. I found that using the item labels as the source for the position doesn't work, perhaps because the item labels are "dissected" by the native picker implementation. However, it does work to use a row of (hidden) placeholders arranged behind the selector.

    The code below uses the technique of .matchedGeometryEffect. This requires a namespace:

    @Namespace private var ns
    

    To make the implementation a little simpler, I also made the enum CaseIterable:

    enum periods: CaseIterable {
        case A, B, C, D, All
    }
    

    The overlay is then applied like this:

    Picker("Please choose a currency", selection: $predefinedPeriod) {
        Text("A").tag(periods.A)
        Text("B").tag(periods.B)
        Text("C").tag(periods.C)
        Text("D (\(predefinedPeriodD))").tag(periods.D)
        Text("All").tag(periods.All)
    }
    .pickerStyle(SegmentedPickerStyle())
    .background {
    
        // A row of placeholders
        HStack(spacing: 0) {
            ForEach(periods.allCases, id: \.self) { period in
                Color.clear
                    .matchedGeometryEffect(id: period, in: ns, isSource: true)
            }
        }
    }
    .overlay {
        Rectangle()
            .fill(.background)
            .opacity(0.001)
            .matchedGeometryEffect(id: predefinedPeriod, in: ns, isSource: false)
            .simultaneousGesture(LongPressGesture().onEnded { _ in
                print("Got Long Press on \(predefinedPeriod)")
                if predefinedPeriod == .D {
                    showModalSelectD.toggle()
                }
            })
            .simultaneousGesture(TapGesture().onEnded{
                print("Got Tap on \(predefinedPeriod)")
                if predefinedPeriod == .D {
                    showModalSelectD.toggle()
                }
            })
    }