What I'm trying to do:
To pass an array (ideally with simple ViewBuilder
syntax) of View
s 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:
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...
// }
}
}
}
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
}
}
}
}
}
}
}
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.
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 })
}
}