Library design: cards + redesigned list on the bookmarks screen, tag picker sheet #9
@@ -376,6 +376,88 @@ struct LibraryTagStrip: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Flow layout
|
||||||
|
|
||||||
|
/// Wraps subviews onto as many rows as fit, capped at `maxRows`. Anything past
|
||||||
|
/// the cap is placed off-screen at zero size rather than skipped — a Layout
|
||||||
|
/// that declines to place a subview gets it laid out at the origin instead of
|
||||||
|
/// dropped, which would stack leftover tags on top of the first row.
|
||||||
|
struct FlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 5
|
||||||
|
var lineSpacing: CGFloat = 5
|
||||||
|
var maxRows: Int = 2
|
||||||
|
|
||||||
|
struct Row {
|
||||||
|
var indices: [Int] = []
|
||||||
|
var width: CGFloat = 0
|
||||||
|
var height: CGFloat = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
|
||||||
|
let rows = rows(width: proposal.width ?? .infinity, subviews: subviews)
|
||||||
|
let height = rows.reduce(0) { $0 + $1.height }
|
||||||
|
+ CGFloat(max(0, rows.count - 1)) * lineSpacing
|
||||||
|
return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(
|
||||||
|
in bounds: CGRect,
|
||||||
|
proposal: ProposedViewSize,
|
||||||
|
subviews: Subviews,
|
||||||
|
cache: inout Void
|
||||||
|
) {
|
||||||
|
let rows = rows(width: bounds.width, subviews: subviews)
|
||||||
|
let placed = Set(rows.flatMap(\.indices))
|
||||||
|
var y = bounds.minY
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
var x = bounds.minX
|
||||||
|
for i in row.indices {
|
||||||
|
let size = subviews[i].sizeThatFits(.unspecified)
|
||||||
|
subviews[i].place(
|
||||||
|
at: CGPoint(x: x, y: y),
|
||||||
|
anchor: .topLeading,
|
||||||
|
proposal: ProposedViewSize(size)
|
||||||
|
)
|
||||||
|
x += size.width + spacing
|
||||||
|
}
|
||||||
|
y += row.height + lineSpacing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Far enough to be off any row, small enough not to poison the layout
|
||||||
|
// arithmetic the way a near-infinite coordinate would.
|
||||||
|
for i in subviews.indices where !placed.contains(i) {
|
||||||
|
subviews[i].place(at: CGPoint(x: bounds.minX - 10_000, y: bounds.minY),
|
||||||
|
anchor: .topLeading,
|
||||||
|
proposal: .zero)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rows(width: CGFloat, subviews: Subviews) -> [Row] {
|
||||||
|
var rows: [Row] = []
|
||||||
|
var current = Row()
|
||||||
|
|
||||||
|
for i in subviews.indices {
|
||||||
|
let size = subviews[i].sizeThatFits(.unspecified)
|
||||||
|
let needed = current.indices.isEmpty ? size.width : current.width + spacing + size.width
|
||||||
|
if needed > width && !current.indices.isEmpty {
|
||||||
|
rows.append(current)
|
||||||
|
if rows.count == maxRows { return rows }
|
||||||
|
current = Row()
|
||||||
|
current.indices = [i]
|
||||||
|
current.width = size.width
|
||||||
|
current.height = size.height
|
||||||
|
} else {
|
||||||
|
current.indices.append(i)
|
||||||
|
current.width = needed
|
||||||
|
current.height = max(current.height, size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.indices.isEmpty { rows.append(current) }
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Open path: up the left edge, across the top, down the right — no bottom
|
/// Open path: up the left edge, across the top, down the right — no bottom
|
||||||
/// stroke, so the tab merges into the page.
|
/// stroke, so the tab merges into the page.
|
||||||
struct TabOutline: Shape {
|
struct TabOutline: Shape {
|
||||||
|
|||||||
@@ -110,37 +110,25 @@ struct LibraryListRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tags wrap onto up to two lines rather than scrolling sideways. At the
|
||||||
|
/// scaled-up type a horizontal strip clipped its third chip mid-word, which
|
||||||
|
/// read as broken text; wrapping shows whole tags or none.
|
||||||
private var tagRow: some View {
|
private var tagRow: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
FlowLayout(spacing: 5, lineSpacing: 5, maxRows: 2) {
|
||||||
HStack(spacing: 5) {
|
ForEach(effectiveTags, id: \.self) { tag in
|
||||||
ForEach(effectiveTags, id: \.self) { tag in
|
Text(tag)
|
||||||
Text(tag)
|
.font(.system(size: 15, design: .monospaced))
|
||||||
.font(.system(size: 15, design: .monospaced))
|
.foregroundStyle(Paper.ink.opacity(0.55))
|
||||||
.foregroundStyle(Paper.ink.opacity(0.55))
|
.lineLimit(1)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.stroke(Paper.rule.opacity(0.6), lineWidth: 0.6)
|
.stroke(Paper.rule.opacity(0.6), lineWidth: 0.6)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 1)
|
|
||||||
}
|
}
|
||||||
.scrollBounceBehavior(.basedOnSize)
|
.padding(.top, 1)
|
||||||
// Without this the strip clips a tag mid-word at the trailing edge and
|
|
||||||
// reads as broken text rather than as something you can scroll.
|
|
||||||
.mask(
|
|
||||||
LinearGradient(
|
|
||||||
stops: [
|
|
||||||
.init(color: .black, location: 0),
|
|
||||||
.init(color: .black, location: 0.9),
|
|
||||||
.init(color: .clear, location: 1),
|
|
||||||
],
|
|
||||||
startPoint: .leading,
|
|
||||||
endPoint: .trailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Excerpt shown under the title: prefer the AI summary (italic), else the
|
/// Excerpt shown under the title: prefer the AI summary (italic), else the
|
||||||
|
|||||||
Reference in New Issue
Block a user