Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

PickerView Keyboard & Tap to Dismiss SwiftUI

Xcode 11.2.1, Swift 5.0

I want to try and replicate this feature that Apple has in their health app:

PickerView Feature

The feature includes a couple of things:

  1. PickerView presented sort of like a keyboard on tap.

  2. PickerView is dismissed on tap outside.

So far, I have tried to get things very similar to this up and running. For example, I was trying to get an alert with a custom body (that would fill the whole screen) that you could call from any view working, but I found it challenging for a number of reasons, including but not limited by: being restricted to positioning a view in its own local coordinate system, not being able to add a view on top of all other views in SwiftUI's layout system, etc. The keyboard-like presentation style of the picker makes it tricky to lay it out on top of everything else, similar to the alert. With respect to the feature of dismissing on tap outside, I have also tried to implement this in some capacity with no success. The ideal way to do this would be to add a gesture recognizer of sorts in front of everything but the child view (in my experiments with this, I was trying to add this for a TextField). This proved to be a challenging task as, as mentioned previously, it is very tricky to lay things out in the global coordinate system irrespective of the child you are laying it out from. I thought that maybe some way of navigating through the view hierarchy from the child view may do the trick, but I am not sure how to do this (may preference keys or environment variables, I'm not sure), don't if this would work at all, and don't know if something that meets this specification to meaningful level really even exists in SwiftUI. I suspect that interfacing with UIKit will probably be the only solution but I am not sure how to properly do this for this feature.

My Current Attempt


struct ContentView: View {
    @State var present = false
    @State var date = Date()

    var formattedDate: String {
        let formatter = DateFormatter()
        formatter.dateStyle = .long
        return formatter.string(from: date)
    }
    
    var body: some View {
        VStack {
            
            HStack {
                Rectangle()
                    .fill(Color.green)
                    .cornerRadius(15)
                    .frame(width: 100, height: 100)
                
                Rectangle()
                    .fill(Color.red)
                    .cornerRadius(15)
                    .frame(height: 100)
                    .overlay(Text(formattedDate).foregroundColor(.white))
                    .shadow(radius: 10)
                    .onTapGesture { self.present.toggle() }
                    .padding(20)
                    .alert(isPresented: $present, contents: {
                        
                        VStack {
                            Spacer()
                            HStack {
                                Spacer()
                                DatePicker(selection: self.$date, displayedComponents: .date) { EmptyView() }
                                    .labelsHidden()
                                Spacer()
                            }
                            .padding(.bottom, 20)
                            .background(BlurView(style: .light).blendMode(.plusLighter))
                            
                        }
                    }) {
                        withAnimation(.easeInOut) {
                            self.present = false
                        }
                }
            }
        }
        
    }
}

struct BlurView: UIViewRepresentable {
    
    typealias UIViewType = UIVisualEffectView
    
    let style: UIBlurEffect.Style
    
    func makeUIView(context: Context) -> UIViewType {
        let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: style))
        return visualEffectView
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) { }
}

extension View {

func alert<Contents: View>(isPresented: Binding<Bool>, contents: @escaping () -> Contents, onBackgroundTapped: @escaping () -> Void = { }) -> some View {

    self.modifier(AlertView(isPresented: isPresented, contents: contents, onBackgroundTapped: onBackgroundTapped))
    }
}


struct AlertView<Contents: View>: ViewModifier {
    @Binding var isPresented: Bool
    var contents: () -> Contents
    var onBackgroundTapped: () -> Void = { }

    @State private var rect: CGRect = .zero


    func body(content: Content) -> some View {
        if rect == .zero {
            return AnyView(
                content.bindingFrame(to: $rect))
        } else {
            return AnyView(content
                .frame(width: rect.width, height: rect.height)
                .overlay(
                    ZStack(alignment: .center) {
                        Color
                            .black
                            .opacity(isPresented ? 0.7 : 0)
                            .edgesIgnoringSafeArea(.all)
                            .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
                            .position(x: UIScreen.main.bounds.midX, y: UIScreen.main.bounds.midY, in: .global)
                            .onTapGesture {
                                self.onBackgroundTapped()
                            }
                            .animation(.easeInOut)


                        contents()
                            .position(x: UIScreen.main.bounds.midX, y: isPresented ? UIScreen.main.bounds.midY : (UIScreen.main.bounds.midY + UIScreen.main.bounds.height), in: .global)
                            .animation(.spring())
                    }
                    .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height),
                    alignment: .center))
        }

    }
}

public extension View {
    func position(_ position: CGPoint, in coordinateSpace: CoordinateSpace) -> some View {
        return self.position(x: position.x, y: position.y, in: coordinateSpace)
    }
    func position(x: CGFloat, y: CGFloat, in coordinateSpace: CoordinateSpace) -> some View {
        GeometryReader { geometry in
            self.position(x: x - geometry.frame(in: coordinateSpace).origin.x, y: y - geometry.frame(in: coordinateSpace).origin.y)
        }
    }
}

struct GeometryGetter: View {
    @Binding var rect: CGRect
    
    var body: some View {
        return GeometryReader { geometry in
            self.makeView(geometry: geometry)
        }
    }
    
    func makeView(geometry: GeometryProxy) -> some View {
        DispatchQueue.main.async {
            self.rect = geometry.frame(in: .global)
        }
        
        return Rectangle().fill(Color.clear)
    }
}

extension View {
    func bindingFrame(to binding: Binding<CGRect>) -> some View {
        self.background(GeometryGetter(rect: binding))
    }
}

Problems:

  • Since the animation can't really know that the view only takes up the bottom part of the screen, it makes it build out way too quickly because the final distance is further.

  • I cannot use the safe area of the device to pad my view, so I just end up guessing to pad it by 20 on the bottom, but that is not a very scalable solution.

  • I do not this will scale up very well. Quite frankly, this feels like a bit of a hacky solution as we don't really want to layout the view in the child's bounds, but we are sort of forced to do so. Moreover, you run into problems immediately if the child view this is being called on is not at the highest z-index position on the screen, as other items will obstruct it and the alert (picker pop-up).

Note: I'm sure there are more issues with it than I mentioned I just have not been able to think of them at the moment, and anyways, those are some of the primary issues.


How can I replicate these effectively features in SwiftUI? I would like to be able to activate this from a child view (like a child view in a separate file, not the root view).

Note: Interfacing with UIKit is a totally acceptable solution as long as it is only an implementation detail and acts as a regular SwiftUI view does for the most part.

Thank you!

like image 965
Noah Wilder Avatar asked Oct 15 '22 08:10

Noah Wilder


1 Answers

I was trying to do something similar to this. Here is a half-finished solution (no Geometry Reader to push up the screen, and not using BlurView).

For me, the critical stumbling block was using the mask on the TapGesture, otherwise my onTapGesture's that I previously used swallowed all Form taps.

struct BottomSheetPicker: View {
    @Binding var selection: Double

    var min: Double = 0.0
    let max: Double
    var by: Double = 1.0
    var format: String = "%d"

    var body: some View {
        Group {
            Picker("", selection: $selection) {
                ForEach(Array(stride(from: min, through: max, by: by)), id: \.self) { item in
                    Text(String(format: self.format, item))
                }
            }
            .pickerStyle(WheelPickerStyle())
            .foregroundColor(.white)
            .frame(height: 180)
            .padding()
        }.background(Color.gray)
    }
}

And here is the main content view:

struct MyContentView: View {
    @State private var heightCm = 0.0
    @State private var weightKg = 0.0

    @State private var pickerType = 0 // TODO: Update to Enum
    private var isShowingOverlay: Bool {
        get {
            return self.pickerType != 0
        }
    }

    private let heightFormat = "%.0f cm"
    private let weightFormat = "%.1f kg"

    private var heightPicker: some View {
        BottomSheetPicker(selection: $heightCm, max: 300, format: heightFormat)
    }

    private var weightPicker: some View {
        BottomSheetPicker(selection: $weightKg, max: 200, by: 0.5, format: weightFormat)
    }

    // There are a few nicer ways to do this, just wanted to whip up a quick demo
    private var pickerOverlay: some View {
        Group {
            if pickerType == 1 {
                heightPicker
            } else if pickerType == 2 {
                weightPicker
            } else {
                EmptyView()
            }
        }
    }

    var body: some View {
        ZStack {
            Form {
                Section(header: Text("Personal Information")) {
                    HStack {
                        Text("Height")
                        Spacer()
                        Text(String(format: heightFormat, self.heightCm))
                    }
                    .contentShape(Rectangle())
                    .onTapGesture {
                        self.pickerType = 1
                    }

                    HStack {
                        Text("Weight")
                        Spacer()
                        Text(String(format: weightFormat, self.weightKg))
                    }
                    .contentShape(Rectangle())
                    .onTapGesture {
                        self.pickerType = 2
                    }
                }
            }
            .gesture(TapGesture().onEnded({
                self.pickerType = 0
            }), including: isShowingOverlay ? .all : .none)

            if isShowingOverlay {
                pickerOverlay
                    .frame(alignment: .bottom)
                    .transition(AnyTransition.move(edge: .bottom))
                    .animation(.easeInOut(duration: 0.5))
            }
        }
    }
}
like image 193
SJoshi Avatar answered Oct 19 '22 01:10

SJoshi