Page view in SwiftUI

I have an app for musicians that works with Songs and Setlists. The logical structure is as follows:

  • A Setlist contains Songs.
  • A Song has Sections, which include Lines (chords & lyrics).

I want to view my Setlist in a "Page View," similar to a book where I can swipe through pages. In this view, the Song Sections are wrapped into columns to save screen space. I use a ColumnsLayout to calculate and render the columns, and then a SplitToPages modifier to divide these columns into pages.

Problem: The TabView sometimes behaves unexpectedly when a song spans multiple pages during rendering. This results in a transition that is either not smooth or stops between songs.

Is there a better way to implement this behavior? Any advice would be greatly appreciated.

struct TestPageView: View {
    struct SongWithSections: Identifiable {
        var id = UUID()
        var title: String
        var section: [String]
    }

    var songSetlistSample: [SongWithSections] {
        var songs: [SongWithSections] = []
        //songs
        for i in 0...3 {
            var sections: [String] = []
            for _ in 0...20 {
                sections.append(randomSection() + "\n\n")
            }
            songs.append(SongWithSections(title: "Song \(i)", section: sections))
        }
        return songs
    }

    func randomSection() -> String {
        var randomSection = ""
        for _ in 0...15 {
            randomSection.append(String((0..<Int.random(in: 3..<10)).map{ _ in "abcdefghijklmnopqrstuvwxyz".randomElement()! }) + " ")
        }
        return randomSection
    }

    var body: some View {
        GeometryReader {geo in
            TabView {
                ForEach(songSetlistSample, id:\.id) {song in
                    let columnWidth = geo.size.width / 2
                    
                    //song
                    ColumnsLayout(columns: 2, columnWidth: columnWidth, height: geo.size.height) {
                        Text(song.title)
                            .font(.largeTitle)
                        
                        ForEach(song.section, id:\.self) {section in
                            Text(section)
                        }
                    }
                    .modifier(SplitToPages(pageWidth: geo.size.width, id: song.id))
                }
            }
            .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
        }
    }
}

public struct ColumnsLayout: Layout {
    var columns: Int
    let columnWidth: CGFloat
    let height: CGFloat
    let spacing: CGFloat = 10
    
    public static var layoutProperties: LayoutProperties {
        var properties = LayoutProperties()
        properties.stackOrientation = .vertical
        return properties
    }

struct Column {
        var elements: [(index: Int, size: CGSize, yOffset: CGFloat)] = []
        var xOffset: CGFloat = .zero
        var height: CGFloat = .zero
    }
    
    public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
        let columns = arrangeColumns(proposal: proposal, subviews: subviews, cache: &cache)
        guard let maxHeight = columns.map({ $0.height}).max() else {return CGSize.zero}
        
        let width = Double(columns.count) * self.columnWidth
        return CGSize(width: width, height: maxHeight)
    }
    
    public func placeSubviews(in bounds: CGRect,
                              proposal: ProposedViewSize,
                              subviews: Subviews,
                              cache: inout Cache) {
        
        let columns = arrangeColumns(proposal: proposal, subviews: subviews, cache: &cache)
        
        for column in columns {
            for element in column.elements {
                let x: CGFloat = column.xOffset
                let y: CGFloat = element.yOffset
                let point = CGPoint(x: x + bounds.minX, y: y + bounds.minY)
                let proposal = ProposedViewSize(width: self.columnWidth, height: proposal.height ?? 100)
                subviews[element.index].place(at: point, anchor: .topLeading, proposal: proposal)
            }
        }
    }
    
    private func arrangeColumns(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> [Column] {
        var currentColumn = Column()
        var columns = [Column]()
        var colNumber = 0
        var currentY = 0.0
        
        for index in subviews.indices {
            let proposal = ProposedViewSize(width: self.columnWidth, height: proposal.height ?? 100)
            
            let size = subviews[index].sizeThatFits(proposal)
            let spacing = size.height > 0 ? spacing : 0
            
            if currentY + size.height > height  {
                currentColumn.height = currentY
                columns.append(currentColumn)
                
                colNumber += 1
                currentColumn = Column()
                currentColumn.xOffset = Double(colNumber) * (self.columnWidth)
                currentY = 0.0
            }
            
            currentColumn.elements.append((index, size, currentY))
            currentY += size.height + spacing
        }
        
        currentColumn.height = currentY
        columns.append(currentColumn)
        
        return columns
    }
}


struct SplitToPages: ViewModifier {
    let pageWidth: CGFloat
    let id: UUID
    
    @State private var pages = 1
    
    func body(content: Content) -> some View {
        let contentWithGeometry = content
            .background(
            GeometryReader { geometryProxy in
                Color.clear
                    .onChange(of: geometryProxy.size) {newSize in
                        guard newSize.width > 0, pageWidth > 0 else {return}
                        pages = Int(ceil(newSize.width / pageWidth))
                    }
                    .onAppear {
                        guard geometryProxy.size.width > 0, pageWidth > 0 else {return}
                        pages = Int(ceil(geometryProxy.size.width /  pageWidth))
                    }
            })

        Group {
            ForEach(0..<pages, id:\.self) {p in
                ZStack(alignment: .topLeading) {
                    contentWithGeometry
                        .offset(x: -Double(p) * pageWidth, y: 0)
                        .frame(width: pageWidth, alignment: .leading)
                    
                    VStack {
                        Spacer()
                        HStack {
                            Spacer()
                            Text("\(p + 1) of \(pages)")
                                .padding([.leading, .trailing])
                        }
                    }
                }
                .id(id.description + p.description)
            }
        }
    }
}

Page view in SwiftUI
 
 
Q