swiftswiftuivstackhstack

Layout question for a numbered list in SwiftUI


I am working on a NumberedList component which works like the <ol> tag in HTML. I'd like to be able provide an arbitrary number of elements, that would be automatically numbered and laid out. However there is one aspect of the layout I can't quite figure out.

I have created an OrderedList view (the equivalent of the <ol> HTML tag), which is using a _VariadicView.Tree and a custom _VariadicView_UnaryViewRoot layout.

public struct OrderedList<Content: View>: View {
    let content: Content
    
    @Environment(\.listLevel)
    var level
    
    public init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
    
    public var body: some View {
        let layout = OrderedListLayout(level: level)
        
        _VariadicView.Tree(layout) {
            content
        }
    }
}

struct OrderedListLayout: _VariadicView_UnaryViewRoot {
    let level: ListLevel
    
    @ViewBuilder
    func body(children: _VariadicView.Children) -> some View {
        let padding: Double = switch level {
        case .first:
            8
        case .second, .third:
            -6
        }
        
        VStack(alignment: .leading, spacing: 8) {
            ForEach(Array(children.enumerated()), id: \.element.id) { index, child in
                HStack(alignment: .centerOfFirstLine, spacing: 8) {
                    Text(verbatim: "\(index + 1).")
                    
                    child
                }
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(.leading, padding)
    }
}

This list is meant to be used with a ListElement view (the equivalent of the <li> HTML tag), which can contain an arbitrary label and an optional child view.

public struct ListElement<Label: View, Child: View>: View {
    let label: Label
    
    let child: Child?
    
    @Environment(\.listLevel)
    var level
    
    public init(@ViewBuilder label: () -> Label, @ViewBuilder child: () -> Child) {
        self.label = label()
        self.child = child()
    }
    
    public var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            label
            
            if let child {
                child
                    .environment(\.listLevel, level.next())
            }
        }
    }
}

public extension ListElement where Child == EmptyView {
    init(@ViewBuilder label: () -> Label) {
        self.label = label()
        self.child = nil
    }
}

This screenshot shows the current result. I have added a green border to the numbering texts and blue border to the list elements to better show how they are not aligned. I would like to be able to lay out the elements so that the leading of all elements in the same list level are aligned. Essentially I want all numbering texts to have the same width.

I could have technically used a Grid to help with that kind of layout. However, I don't think this would work since I want to be able to nest multiple lists if needed. I think the solution would probably lie in either using a custom HorizontalAlignment guide or a custom Layout, but I can't quite wrap my head around it.

EDIT: I forgot to mention that my project has a deployment target of iOS 15. Nesting Grids actually works to achieve the layout I want, but doesn't meet the deployment target.


Solution

  • Using a Grid works alright for me. I'm not sure why you would think it wouldn't work with nested ordered lists. You'd just end up with nested Grids, and there is nothing wrong with that.

    struct OrderedListLayout: _VariadicView_UnaryViewRoot {
        let level: ListLevel
        
        @ViewBuilder
        func body(children: _VariadicView.Children) -> some View {
            let padding: Double = switch level {
            case .first:
                8
            case .second, .third:
                -6
            }
            
            Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 8, verticalSpacing: 8) {
                ForEach(Array(children.enumerated()), id: \.element.id) { index, child in
                    GridRow {
                        Text(verbatim: "\(index + 1).")
                            .gridColumnAlignment(.trailing)
                        child
                    }
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(.leading, padding)
        }
    }