Search code examples
swiftuiviewforeachargumentsprotocols

Pass an array of Views conforming to a protocol as an argument, and iterate through them


What I'm trying to do:

To pass an array (ideally with simple ViewBuilder syntax) of Views conforming to a protocol to some Layout view's init, so that the Layout view can iterate through the passed views and display them, and also be able to cast them to their protocol type and use the methods declared in this protocol.

Why do I need this:

I want to recreate Apple's Form, so that the fields in my form can be auto-cycled by pressing keyboard's next button. Since the default Form forces substantial visual changes and in general is very limited

What I tried so far:

  1. Layout protocol

     // the protocol which asks the field to be able to detect its `next` button press
     protocol FormField: View {
         var nextButtonPushPublisher: PassthroughSubject<Void, Never> { get }
     }
    
     // one of the complying field types
     struct AppTextField: FormField {
         // ...
     }
    
     struct AppForm: Layout {
    
         func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
             let idealViewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
             let height = idealViewSizes.reduce(0) { $0 + $1.height }
    
             return CGSize(width: proposal.width ?? 0, height: height)
         }
    
         func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
             let origin = CGPoint(x: bounds.minX, y: bounds.minY)
             var currentY = 0.0
    
             for v in subviews {
                 let idealViewSize = v.sizeThatFits(.unspecified)
                 let x = origin.x
                 let y = origin.y + currentY - idealViewSize.height/2
                 let point = CGPoint(x: x, y: y)
    
                 v.place(at: point, anchor: .topLeading, proposal: .unspecified)
    
                 currentY += idealViewSize.height
    
                 // <--- here I would like to do something like:
                 // if let field = v as? FormField { <--- nil
                 //     field.nextButtonPushPublisher...
                 // }
             }
         }
     }
    
  2. Passing as a TupleView (doesn't compile)

    struct AppForm: View {
    
         private let views: [any FormField]
    
         @FocusState private var focus: Int?
    
         init<View: FormField>(@ViewBuilder content: @escaping () -> TupleView<View>) {
             self.views = content() // <--- error: Cannot assign value of type 'TupleView<View>' to type '[any FormField]'
             self.focus = 0
         }
    
         var body: some View {
             VStack {
                 ForEach(0..<views.count, id: \.self) { index in
                     let field = views[index]
                     if let f = field as? (any FormField) {
                         field
                             .focused($focus, equals: index)
                             .onReceive(f.nextButtonPushPublisher) { p in
                                 if var focus {
                                     focus += 1
                                 }
                             }
                     }
                 }
             }
         }
     }
    
  3. Using 'any FormField' (doesn't compile)

     struct AppForm: View {
    
         private let views: [any FormField]
    
         public init(_ content: any FormField...) {
             self.views = content
         }
    
         var body: some View {
             VStack {
                 ForEach(0..<views.count, id: \.self) { index in
                     views[index] // <--- error: No exact matches in reference to static method 'buildExpression'
                 }
             }
         }
     }
    

None of them work.

Fields inside the form can be of different types, but all conforming to FormField, which means I cannot pass them as a generic for Form which only wants one concrete type (or I don't know how). I could pass them as AnyView, but then they are impossible to cast back to FormField. I feel like there must be another approach, please help.


Solution

  • Instead of a protocol that requires a PassthroughSubject, you can follow the existing patterns that SwiftUI uses - write an environment key that represents a "next" action.

    struct NextAction {
        let action: () -> Void
        
        init(action: @escaping () -> Void) {
            self.action = action
        }
        
        func callAsFunction() {
            action()
        }
    }
    
    struct NextActionKey: EnvironmentKey {
        static var defaultValue: NextAction { NextAction {} }
    }
    
    extension EnvironmentValues {
        var next: NextAction {
            get { self[NextActionKey.self] }
            set { self[NextActionKey.self] = newValue }
        }
    }
    

    Usage:

    struct FormTextField: View {
        @Environment(\.next) var next
        @State var text = ""
        
        @Environment(\.isFocused) var focused
        
        var body: some View {
            HStack {
                TextField("Foo", text: $text)
                    .toolbar {
                        ToolbarItem(id: "next", placement: .keyboard){
                            Button("Next") {
                                next()
                            }
                        }
                    }
            }
        }
    }
    

    Now in AppForm, you can just set the next environment value to something that increments focus.

    .environment(\.next, NextAction { focus? += 1 })
    

    All that's left is to do .focused($focus, equals: i) on all the subviews. You can use View Extractor to do this.

    struct AppForm<Content: View>: View {
        @FocusState private var focus: Int?
        let content: Content
        
        init(@ViewBuilder content: () -> Content) {
            self.content = content()
        }
        
        var body: some View {
            VStack {
                ExtractMulti(content) { views in
                    ForEach(Array(views.enumerated()), id: \.1.id) { (i, view) in
                        view.focused($focus, equals: i)
                    }
                }
            }
            .environment(\.next, NextAction { focus? += 1 })
        }
    }