crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

Layouts.swift (4034B)


      1 import SwiftUI
      2 
      3 /// Lays subviews left-to-right, wrapping onto a new line when the next
      4 /// subview would overflow the proposed width. Hugs its content width (the
      5 /// widest row) so a surrounding view can size to it and centre the block;
      6 /// rows are aligned per `alignment` — centred by default, or leading.
      7 struct FlowLayout: Layout {
      8     enum Alignment {
      9         case leading
     10         case center
     11     }
     12 
     13     var alignment: Alignment = .center
     14     var spacing: CGFloat = 18
     15     var lineSpacing: CGFloat = 8
     16 
     17     private struct Row {
     18         var indices: [Int] = []
     19         var width: CGFloat = 0
     20         var height: CGFloat = 0
     21     }
     22 
     23     private func rows(_ subviews: Subviews, maxWidth: CGFloat) -> [Row] {
     24         var rows: [Row] = []
     25         var current = Row()
     26         for index in subviews.indices {
     27             let size = subviews[index].sizeThatFits(.unspecified)
     28             let needed = current.indices.isEmpty
     29                 ? size.width
     30                 : current.width + spacing + size.width
     31             if !current.indices.isEmpty, needed > maxWidth {
     32                 rows.append(current)
     33                 current = Row(indices: [index], width: size.width, height: size.height)
     34             } else {
     35                 if !current.indices.isEmpty { current.width += spacing }
     36                 current.indices.append(index)
     37                 current.width += size.width
     38                 current.height = max(current.height, size.height)
     39             }
     40         }
     41         if !current.indices.isEmpty { rows.append(current) }
     42         return rows
     43     }
     44 
     45     func sizeThatFits(
     46         proposal: ProposedViewSize,
     47         subviews: Subviews,
     48         cache: inout ()
     49     ) -> CGSize {
     50         let rows = rows(subviews, maxWidth: proposal.width ?? .infinity)
     51         let height = rows.reduce(0) { $0 + $1.height }
     52             + lineSpacing * CGFloat(max(0, rows.count - 1))
     53         let widest = rows.map(\.width).max() ?? 0
     54         return CGSize(width: widest, height: height)
     55     }
     56 
     57     func placeSubviews(
     58         in bounds: CGRect,
     59         proposal: ProposedViewSize,
     60         subviews: Subviews,
     61         cache: inout ()
     62     ) {
     63         let rows = rows(subviews, maxWidth: bounds.width)
     64         var y = bounds.minY
     65         for row in rows {
     66             // Align each row within the allocated width. The block hugs its
     67             // widest row, so leading rows start flush at the edge and only a
     68             // wider sibling shifts a short row when centred.
     69             var x = bounds.minX
     70             if alignment == .center {
     71                 x += max(0, (bounds.width - row.width) / 2)
     72             }
     73             for index in row.indices {
     74                 let size = subviews[index].sizeThatFits(.unspecified)
     75                 subviews[index].place(
     76                     at: CGPoint(x: x, y: y),
     77                     anchor: .topLeading,
     78                     proposal: ProposedViewSize(size)
     79                 )
     80                 x += size.width + spacing
     81             }
     82             y += row.height + lineSpacing
     83         }
     84     }
     85 }
     86 
     87 struct WeightedVStack: Layout {
     88     let weights: [CGFloat]
     89 
     90     func sizeThatFits(
     91         proposal: ProposedViewSize,
     92         subviews: Subviews,
     93         cache: inout ()
     94     ) -> CGSize {
     95         CGSize(
     96             width: proposal.width ?? 0,
     97             height: proposal.height ?? 0
     98         )
     99     }
    100 
    101     func placeSubviews(
    102         in bounds: CGRect,
    103         proposal: ProposedViewSize,
    104         subviews: Subviews,
    105         cache: inout ()
    106     ) {
    107         let totalWeight = weights.reduce(0, +)
    108         guard totalWeight > 0 else { return }
    109 
    110         var y = bounds.minY
    111         for (index, subview) in subviews.enumerated() {
    112             let weight = index < weights.count ? weights[index] : 0
    113             let height = bounds.height * weight / totalWeight
    114             subview.place(
    115                 at: CGPoint(x: bounds.minX, y: y),
    116                 anchor: .topLeading,
    117                 proposal: ProposedViewSize(width: bounds.width, height: height)
    118             )
    119             y += height
    120         }
    121     }
    122 }