Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apple/TableProMobile/TableProMobile/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -4329,6 +4329,9 @@
"Car" : {
"extractionState" : "manual",
"generatesSymbol" : false
},
"Cards" : {

},
"Cart" : {
"extractionState" : "manual",
Expand Down Expand Up @@ -12942,6 +12945,9 @@
"Layers" : {
"extractionState" : "manual",
"generatesSymbol" : false
},
"Layout" : {

},
"Leaf" : {
"extractionState" : "manual",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ nonisolated enum AppPreferences {
static let defaultPageSizeKey = "com.TablePro.settings.defaultPageSize"
static let defaultSafeModeKey = "com.TablePro.settings.defaultSafeMode"
static let hideQueryPreviewInActivityKey = "com.TablePro.settings.hideQueryPreviewInActivity"
static let rowLayoutKey = "com.TablePro.settings.rowLayout"

static let pageSizeOptions: [Int] = [50, 100, 200, 500]

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import SwiftUI
import TableProModels

nonisolated enum RowKey {
static func columnIndex(columns: [ColumnInfo], primaryKeys: Set<String>) -> Int? {
columns.firstIndex { primaryKeys.contains($0.name) } ?? (columns.isEmpty ? nil : 0)
}
}

struct RowCard: View {
let columns: [ColumnInfo]
let columnDetails: [ColumnInfo]
Expand All @@ -12,12 +18,9 @@ struct RowCard: View {
}

private var titlePair: (name: String, value: String)? {
let pks = pkNames
for (col, val) in zip(columns, row) where pks.contains(col.name) {
return (col.name, val ?? "NULL")
}
guard let first = columns.first else { return nil }
return (first.name, row.first.flatMap { $0 } ?? "NULL")
guard let index = RowKey.columnIndex(columns: columns, primaryKeys: pkNames) else { return nil }
let value = row.indices.contains(index) ? row[index] : nil
return (columns[index].name, value ?? "NULL")
}

private var detailPairs: [(name: String, value: String)] {
Expand Down
294 changes: 294 additions & 0 deletions apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
import SwiftUI
import TableProModels
import UIKit

nonisolated struct RowGridMetrics: Equatable, Sendable {
var minimumWidth: CGFloat
var maximumWidth: CGFloat
var horizontalPadding: CGFloat
}

nonisolated struct RowGridLayout: Equatable, Sendable {
static let sampledRowCount = 50
// Past this many characters even the narrowest glyphs overflow the widest column.
static let maximumMeasuredCharacters = 128

let order: [Int]
let widths: [CGFloat]

var totalWidth: CGFloat { widths.reduce(0, +) }

static func make(
columnNames: [String],
rows: some Collection<[String?]>,
keyColumn: Int?,
metrics: RowGridMetrics,
measure: (_ text: String, _ isHeader: Bool) -> CGFloat
) -> RowGridLayout {
let order = displayOrder(columnCount: columnNames.count, keyColumn: keyColumn)
let sample = rows.prefix(sampledRowCount)
let widestText = metrics.maximumWidth - metrics.horizontalPadding * 2
let widths = order.map { column in
var widest = measure(measuredText(columnNames[column]), true)
for row in sample where widest < widestText {
let value = row.indices.contains(column) ? row[column] : nil
widest = max(widest, measure(measuredText(value ?? "NULL"), false))
}
let width = widest.rounded(.up) + metrics.horizontalPadding * 2
return min(max(width, metrics.minimumWidth), metrics.maximumWidth)
}
return RowGridLayout(order: order, widths: widths)
}

// A key column wider than this share of the screen would leave no room to scroll the others past it.
static let keyColumnViewportShare: CGFloat = 0.4

func limitingKeyColumn(toViewportWidth viewportWidth: CGFloat) -> RowGridLayout {
let limit = (viewportWidth * Self.keyColumnViewportShare).rounded(.down)
guard let keyWidth = widths.first, limit > 0, keyWidth > limit else { return self }
return RowGridLayout(order: order, widths: [limit] + widths.dropFirst())
}

static func displayOrder(columnCount: Int, keyColumn: Int?) -> [Int] {
let columns = Array(0..<columnCount)
guard let keyColumn, columns.contains(keyColumn) else { return columns }
return [keyColumn] + columns.filter { $0 != keyColumn }
}

// A cell shows one line, and a value that continues past it ends in an ellipsis.
static func measuredText(_ text: String) -> String {
var end = text.startIndex
var count = 0
while end < text.endIndex, count < maximumMeasuredCharacters, !text[end].isNewline {
text.formIndex(after: &end)
count += 1
}
let measured = String(text[..<end])
return end < text.endIndex ? measured + "…" : measured
}
}

struct RowGridTextMeasure {
let cellFont: UIFont
let headerFont: UIFont

init(dynamicTypeSize: DynamicTypeSize) {
let traits = UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(dynamicTypeSize))
cellFont = UIFont.preferredFont(forTextStyle: .subheadline, compatibleWith: traits)
headerFont = UIFont.systemFont(ofSize: cellFont.pointSize, weight: .semibold)
}

func width(of text: String, isHeader: Bool) -> CGFloat {
(text as NSString).size(withAttributes: [.font: isHeader ? headerFont : cellFont]).width
}
}

nonisolated struct RowGridViewport: Equatable, Sendable {
let width: CGFloat
let leadingInset: CGFloat
}

struct RowGrid<Row: View>: View {
let columns: [ColumnInfo]
let rows: [[String?]]
let keyColumn: Int?
@ViewBuilder let row: (_ index: Int, _ line: RowGridLine) -> Row

@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.displayScale) private var displayScale
@ScaledMetric(relativeTo: .subheadline) private var minimumWidth: CGFloat = 72
@ScaledMetric(relativeTo: .subheadline) private var maximumWidth: CGFloat = 320
@ScaledMetric(relativeTo: .subheadline) private var horizontalPadding: CGFloat = 12
@ScaledMetric(relativeTo: .subheadline) private var verticalPadding: CGFloat = 10
@State private var computedLayout: (key: LayoutKey, layout: RowGridLayout)?
@State private var viewport = RowGridViewport(width: 0, leadingInset: 0)

private var layoutKey: LayoutKey {
LayoutKey(
columnNames: columns.map(\.name),
sample: rows.prefix(RowGridLayout.sampledRowCount),
keyColumn: keyColumn,
dynamicTypeSize: dynamicTypeSize,
metrics: RowGridMetrics(
minimumWidth: minimumWidth, maximumWidth: maximumWidth, horizontalPadding: horizontalPadding
)
)
}

private var style: RowGridLine.Style {
RowGridLine.Style(
horizontalPadding: horizontalPadding,
verticalPadding: verticalPadding,
hairline: HairlineThickness.points(forDisplayScale: displayScale),
pinnedInset: viewport.leadingInset
)
}

var body: some View {
let layout = currentLayout.limitingKeyColumn(toViewportWidth: viewport.width)
ScrollView([.horizontal, .vertical]) {
grid(layout, width: max(layout.totalWidth, viewport.width))
}
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
.defaultScrollAnchor(.topLeading, for: .alignment)
.buttonStyle(.plain)
// Changes only on a resize or an inset change, so scrolling runs no body pass. The container is the
// visible width; the insets are where the scroll view reaches under a sidebar or into the safe area.
.onScrollGeometryChange(for: RowGridViewport.self) { geometry in
RowGridViewport(width: geometry.containerSize.width, leadingInset: geometry.contentInsets.leading)
} action: { _, newViewport in
viewport = newViewport
}
// Reads only the key: the closure's other captures belong to the previous body.
.onChange(of: layoutKey, initial: true) { _, key in
computedLayout = (key, Self.makeLayout(key))
}
}

// Until onChange stores the layout for these inputs, it is measured here, so no pass draws an empty grid.
private var currentLayout: RowGridLayout {
let key = layoutKey
if let computedLayout, computedLayout.key == key { return computedLayout.layout }
return Self.makeLayout(key)
}

private static func makeLayout(_ key: LayoutKey) -> RowGridLayout {
let measure = RowGridTextMeasure(dynamicTypeSize: key.dynamicTypeSize)
return RowGridLayout.make(
columnNames: key.columnNames,
rows: key.sample,
keyColumn: key.keyColumn,
metrics: key.metrics,
measure: measure.width(of:isHeader:)
)
}

private func grid(_ layout: RowGridLayout, width: CGFloat) -> some View {
let style = style
return LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
Section {
ForEach(IndexedRow.wrap(rows)) { item in
row(item.id, RowGridLine(
columns: columns, values: item.values, layout: layout, width: width, style: style
))
}
} header: {
header(layout, width: width, style: style)
}
}
.frame(width: width, alignment: .leading)
}

private func header(_ layout: RowGridLayout, width: CGFloat, style: RowGridLine.Style) -> some View {
HStack(spacing: 0) {
ForEach(layout.order.indices, id: \.self) { position in
Text(verbatim: columns[layout.order[position]].name)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.lineLimit(1)
.padding(.horizontal, style.horizontalPadding)
.padding(.vertical, style.verticalPadding)
.frame(width: layout.widths[position], alignment: .leading)
.modifier(RowGridCell(isPinned: position == 0, style: style))
}
}
.frame(width: width, alignment: .leading)
.background(Color(.systemBackground))
.overlay(alignment: .bottom) {
Rectangle().fill(Color(.separator)).frame(height: style.hairline)
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isHeader)
}

private struct LayoutKey: Equatable {
let columnNames: [String]
let sample: ArraySlice<[String?]>
let keyColumn: Int?
let dynamicTypeSize: DynamicTypeSize
let metrics: RowGridMetrics
}
}

struct RowGridLine: View {
struct Style {
let horizontalPadding: CGFloat
let verticalPadding: CGFloat
let hairline: CGFloat
let pinnedInset: CGFloat
}

let columns: [ColumnInfo]
let values: [String?]
let layout: RowGridLayout
let width: CGFloat
let style: Style

var body: some View {
HStack(spacing: 0) {
ForEach(layout.order.indices, id: \.self) { position in
cell(value(at: layout.order[position]))
.frame(width: layout.widths[position], alignment: .leading)
.modifier(RowGridCell(isPinned: position == 0, style: style))
}
}
.frame(width: width, alignment: .leading)
.overlay(alignment: .bottom) {
Rectangle().fill(Color(.separator)).frame(height: style.hairline)
}
.contentShape(Rectangle())
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(verbatim: accessibilityText))
}

private func value(at column: Int) -> String? {
values.indices.contains(column) ? values[column] : nil
}

private var accessibilityText: String {
layout.order
.filter { columns.indices.contains($0) }
.map { "\(columns[$0].name), \(value(at: $0) ?? "NULL")" }
.joined(separator: ", ")
}

private func cell(_ value: String?) -> some View {
Group {
if let value {
Text(verbatim: value)
} else {
Text(verbatim: "NULL")
.foregroundStyle(.tertiary)
}
}
.font(.subheadline)
.lineLimit(1)
.padding(.horizontal, style.horizontalPadding)
.padding(.vertical, style.verticalPadding)
}
}

private struct RowGridCell: ViewModifier {
let isPinned: Bool
let style: RowGridLine.Style

func body(content: Content) -> some View {
if isPinned {
let inset = style.pinnedInset
// Offset in the render pass, so a sideways scroll moves no state and rebuilds no row.
content
// Reaches over the leading inset, so cells scrolled past the key never show beside it.
.background(Color(.systemBackground).padding(.leading, -inset))
.overlay(alignment: .trailing) {
Rectangle().fill(Color(.separator)).frame(width: style.hairline)
}
.visualEffect { content, proxy in
content.offset(x: max(0, -proxy.frame(in: .scrollView).minX))
}
.zIndex(1)
} else {
content
}
}
}
Loading
Loading