0

I am trying to replicate this form I've found on the Health app:

Date picker from the Health app

It is a regular sheet. Inside the sheet, there is a Form. Then I use an HStack with Text with the label of the field and another Text with the value, separated by a Spacer. I watch for the environment's "edit" mode, and when it's true, I transform all Stacks into buttons.

When the user taps the date, on Health, a wheel DatePicker shows up. It looks like it's in another sheet, but it's a "bottom sheet", which I could only make work for iOS 16. But that sheet from Health has no radius in its corners. The background is a darker grey, while mine is either white or whatever I define as a Color in a ZStack. On Health, the only way to close the DatePicker's sheet, is by tapping "Cancel" or "Done" at the top. On my version, it simply goes away by tapping outside.

Am I overthinking this? Is there an easier way to implement this form? I'm really tired of the sheer amount of details. My code is getting huge.

Thank you!

Apollo
  • 1,913
  • 2
  • 19
  • 26
  • 1
    You're probably looking for a `UIPickerView` set as the `inputView` for a `UITextField` (see https://stackoverflow.com/questions/15484506/show-uipickerview-like-a-keyboard-without-uitextfield). I'm not sure if there's a SwiftUI equivalent. You may have to drop down to UIKit for that component. – jnpdx Jul 09 '23 at 19:05
  • I had the same struggle with wheel date picker (it wasn't fully customizable without some hacks that required some hacks to underlying UIKit implementation, which didn't work on iOS 16). So I ended up implementing date picker with `.datePickerStyle(.graphical)`, which can be fully styled with ease. – timbre timbre Jul 10 '23 at 15:03
  • @jnpdx This is exactly right! It looks like Health was all made in UIKit, so I have to mix them up. I've made a `UIViewRepresentable` code to tie in the UIKit and it behaves as expected. Mostly. I'll publish the answer soon, unless you want to do it? Thank you for the insight! – Apollo Jul 10 '23 at 17:58
  • Go for it. Glad it worked – jnpdx Jul 10 '23 at 18:55

1 Answers1

0

As @jnpdx pointed out in a comment to the question, it turns out that the DatePicker on the Health app is written with UIKit and has no equivalent in SwiftUI. So I had to make a bespoke component for that using UIViewRepresentable.

This is what I concocted:

import SwiftUI

class MuteTextField: UITextField {
    override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        false
    }

    override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {
        []
    }

    override func caretRect(for position: UITextPosition) -> CGRect {
        .null
    }
}

struct DatePickerField: UIViewRepresentable {
    @Binding var selectedDate: Date?
    @Binding var isDatePickerVisible: Bool
    var minDate: Date
    var maxDate: Date
    var unset: String
    
    func makeUIView(context: Context) -> MuteTextField {
        let textField = MuteTextField()
        textField.tintColor = UIColor.clear
        
        let datePicker = UIDatePicker()
        datePicker.datePickerMode = .date
        datePicker.minimumDate = minDate
        datePicker.maximumDate = maxDate
        datePicker.preferredDatePickerStyle = UIDatePickerStyle.wheels
        datePicker.addTarget(context.coordinator, action: #selector(Coordinator.dateChanged(_:)), for: .valueChanged)
        textField.inputView = datePicker
        textField.textColor = .systemBlue
        textField.textAlignment = .right
        
        return textField
    }
    
    func updateUIView(_ uiView: MuteTextField, context: Context) {
        uiView.text = selectedDate == nil ? unset : selectedDate?.formatted(date: .abbreviated, time: .omitted)
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject {
        let parent: DatePickerField
        
        init(_ parent: DatePickerField) {
            self.parent = parent
        }
        
        @objc func dateChanged(_ sender: UIDatePicker) {
            parent.selectedDate = sender.date
        }
    }
}

This can be used on SwiftUI. But it turns out it's a text field, which is not what I wanted, so I hid the field, as can be seen on the UIKit component, when I say textField.isHidden = true. On SwiftUI I used a ZStack to overlap the field with something else that looks like the field in the Health app. It's not pretty, but can be made into a new component later.

It looks roughly like this:

var body: some View {
    ZStack {
        if (editMode == .active) {
            HStack {
                Text(fieldLabel)
                    .foregroundColor(.primary)
                Spacer()
                DatePickerField(
                    selectedDate: $selectedDate,
                    isDatePickerVisible: $visibleInput,
                    minDate: minDate,
                    maxDate: maxDate,
                    unset: fieldUnset
                )
            }
        } else {
            HStack {
                Text(fieldLabel)
                    .foregroundColor(.primary)
                Spacer()
                if selectedDate == nil {
                    Text(fieldUnset)
                        .foregroundColor(.secondary)
                } else {
                    Text(selectedDate!, formatter: mediumDateFormatter)
                        .foregroundColor(.secondary)
                }
            }
        }
    }
}

It's big, but it can be wrapped in another View, so it's not too bad.

The Health app also has a red button to clear the value entirely. My date is optional, so it can be nil. I will probably add the icon later to set it back to nil, but it would be out of scope for this question as my main concern was to show the DatePicker.

I assume a similar approach would be required for the other fields: sex and blood type, which are lists. That component can be expanded or copied to handle that.

Apollo
  • 1,913
  • 2
  • 19
  • 26
  • Your strategy of using `parent` like this is dangerous -- the SwiftUI `View` is transient and can be recreated at any time. Better to pass the `Binding` itself (updating it in `updateUIView`) – jnpdx Jul 10 '23 at 20:07
  • @jnpdx, thanks for pointing out. Right. I've made a new code, I'll update the response soon. – Apollo Jul 12 '23 at 22:01