From 2949f3b8d8d127f8cba940080b9cca4711f1ff83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= <26674308+J2TeamNNL@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:41:34 +0700 Subject: [PATCH 1/4] feat(ios): show table rows in a grid as well as cards The table browser gains a grid layout: one line per row, a pinned header, columns that scroll sideways and a first column that stays in view. A Cards / Grid picker in the More menu stores the choice; until one is made, regular width opens in the grid and compact width keeps the cards. --- .../TableProMobile/Localizable.xcstrings | 6 + .../Views/Components/RowGrid.swift | 152 ++++++++++++++++++ .../Views/DataBrowserView.swift | 62 ++++++- .../TableProMobile/Views/RowLayout.swift | 34 ++++ .../Views/RowLayoutTests.swift | 86 ++++++++++ 5 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift create mode 100644 apple/TableProMobile/TableProMobile/Views/RowLayout.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Views/RowLayoutTests.swift diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index 8ec2eb210d..7d6a1d957a 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -4329,6 +4329,9 @@ "Car" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Cards" : { + }, "Cart" : { "extractionState" : "manual", @@ -12942,6 +12945,9 @@ "Layers" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Layout" : { + }, "Leaf" : { "extractionState" : "manual", diff --git a/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift b/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift new file mode 100644 index 0000000000..7f9dc16b69 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift @@ -0,0 +1,152 @@ +import SwiftUI +import TableProModels + +nonisolated enum RowGridMetrics { + static let minimumColumnWidth: CGFloat = 72 + static let maximumColumnWidth: CGFloat = 320 + static let cellHorizontalPadding: CGFloat = 12 + static let sampledRowCount = 50 + static let maximumMeasuredCharacters = 40 + + /// Sized from the header and the first rows only, so a page of 500 rows costs no more than one of 50. + static func columnWidths(columns: [ColumnInfo], rows: [[String?]], characterWidth: CGFloat) -> [CGFloat] { + let sample = rows.prefix(sampledRowCount) + return columns.indices.map { index in + let longestValue = sample.reduce(0) { longest, row in + guard row.indices.contains(index) else { return longest } + return max(longest, displayLength(row[index] ?? "NULL")) + } + let characters = max(displayLength(columns[index].name), longestValue) + let width = CGFloat(characters) * characterWidth + cellHorizontalPadding * 2 + return min(max(width, minimumColumnWidth), maximumColumnWidth) + } + } + + private static func displayLength(_ text: String) -> Int { + let firstLine = text.prefix { !$0.isNewline } + return min(firstLine.count, maximumMeasuredCharacters) + } +} + +struct RowGrid: View { + let columns: [ColumnInfo] + let rows: [[String?]] + @ViewBuilder let destination: (Int) -> Destination + @ViewBuilder let menuItems: (Int) -> MenuItems + let deleteAction: (Int) -> (() -> Void)? + + @ScaledMetric(relativeTo: .subheadline) private var characterWidth: CGFloat = 8 + @State private var pinnedOffset: CGFloat = 0 + + var body: some View { + let widths = RowGridMetrics.columnWidths(columns: columns, rows: rows, characterWidth: characterWidth) + ScrollView([.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + Section { + ForEach(IndexedRow.wrap(rows)) { item in + rowLink(index: item.id, values: item.values, widths: widths) + } + } header: { + header(widths: widths) + } + } + } + // The first column rides the horizontal offset back into view, so a row keeps its name while the rest scrolls. + .onScrollGeometryChange(for: CGFloat.self) { geometry in + max(geometry.contentOffset.x + geometry.contentInsets.leading, 0) + } action: { _, offset in + pinnedOffset = offset + } + } + + private func header(widths: [CGFloat]) -> some View { + HStack(spacing: 0) { + ForEach(Array(columns.enumerated()), id: \.offset) { index, column in + Text(column.name) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.horizontal, RowGridMetrics.cellHorizontalPadding) + .padding(.vertical, 8) + .frame(width: widths[index], alignment: .leading) + .modifier(PinnedCell(isPinned: index == 0, offset: pinnedOffset)) + } + } + .background(Color(.systemBackground)) + .overlay(alignment: .bottom) { Divider() } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + } + + private func rowLink(index: Int, values: [String?], widths: [CGFloat]) -> some View { + let delete = deleteAction(index) + return NavigationLink { + destination(index) + } label: { + line(values: values, widths: widths) + } + .buttonStyle(.plain) + .hoverEffect() + .contextMenu { + menuItems(index) + if let delete { + Divider() + Button("Delete Row", systemImage: "trash", role: .destructive, action: delete) + } + } + .accessibilityActions { + if let delete { + Button("Delete row", action: delete) + } + } + } + + private func line(values: [String?], widths: [CGFloat]) -> some View { + HStack(spacing: 0) { + ForEach(Array(columns.indices), id: \.self) { index in + let value = values.indices.contains(index) ? values[index] : nil + cell(value) + .frame(width: widths[index], alignment: .leading) + .modifier(PinnedCell(isPinned: index == 0, offset: pinnedOffset)) + } + } + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { Divider() } + .accessibilityElement(children: .combine) + } + + 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, RowGridMetrics.cellHorizontalPadding) + .padding(.vertical, 11) + } +} + +private struct PinnedCell: ViewModifier { + let isPinned: Bool + let offset: CGFloat + + func body(content: Content) -> some View { + if isPinned { + content + .background(Color(.systemBackground)) + .overlay(alignment: .trailing) { + if offset > 0 { Divider() } + } + .offset(x: offset) + .zIndex(1) + } else { + content + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift index b70ab4aa42..01196f2d6a 100644 --- a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -37,6 +37,7 @@ struct DataBrowserView: View { @State private var isCompletingCells = false @State private var hapticSuccess = false @State private var hapticError = false + @AppStorage(RowLayout.storageKey) private var preferredLayout: RowLayout? private var activeSchema: String? { coordinator.supportsSchemas ? coordinator.activeSchema : nil @@ -299,7 +300,28 @@ struct DataBrowserView: View { } } } else { - rowList + rowsView + } + } + + private var rowLayout: RowLayout { + RowLayout.resolved(preferred: preferredLayout, widthClass: duoWidthClass) + } + + private var rowLayoutBinding: Binding { + Binding( + get: { rowLayout }, + set: { preferredLayout = $0 } + ) + } + + @ViewBuilder + private var rowsView: some View { + switch rowLayout { + case .cards: + pageContainer(rowList) + case .grid: + pageContainer(rowGrid) } } @@ -311,12 +333,29 @@ struct DataBrowserView: View { } } .listStyle(.plain) - .id(viewModel.pagination.currentPage) - .opacity(viewModel.isPageLoading ? 0.5 : 1) - .allowsHitTesting(!viewModel.isPageLoading) - .overlay { if viewModel.isPageLoading { ProgressView() } } - .animation(.default, value: viewModel.isPageLoading) - .refreshable { await viewModel.load() } + } + + private var rowGrid: some View { + RowGrid( + columns: columns, + rows: rows, + destination: { index in rowDetail(index: index) }, + menuItems: { index in rowContextMenu(cells: viewModel.cells(at: index)) }, + deleteAction: { index in + guard canDeleteRows, let locator = viewModel.rowLocator(forRowAt: index) else { return nil } + return { confirmDelete(locator) } + } + ) + } + + private func pageContainer(_ rows: some View) -> some View { + rows + .id(viewModel.pagination.currentPage) + .opacity(viewModel.isPageLoading ? 0.5 : 1) + .allowsHitTesting(!viewModel.isPageLoading) + .overlay { if viewModel.isPageLoading { ProgressView() } } + .animation(.default, value: viewModel.isPageLoading) + .refreshable { await viewModel.load() } } private func rowDetail(index: Int) -> some View { @@ -450,6 +489,15 @@ struct DataBrowserView: View { } Divider() } + Picker(selection: rowLayoutBinding) { + ForEach(RowLayout.allCases) { layout in + Label(layout.title, systemImage: layout.systemImage).tag(layout) + } + } label: { + Label("Layout", systemImage: rowLayout.systemImage) + } + .pickerStyle(.inline) + Divider() Section("Export") { ForEach(ExportFormat.offered(for: connection.type)) { format in Button { diff --git a/apple/TableProMobile/TableProMobile/Views/RowLayout.swift b/apple/TableProMobile/TableProMobile/Views/RowLayout.swift new file mode 100644 index 0000000000..4c1ad11de8 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/RowLayout.swift @@ -0,0 +1,34 @@ +import Foundation + +nonisolated enum RowLayout: String, CaseIterable, Identifiable, Sendable { + case cards + case grid + + static let storageKey = "com.TablePro.settings.rowLayout" + + var id: String { rawValue } + + /// A choice the user made wins at every width; until then a regular width opens in the grid + /// and a compact one keeps the cards, which fit a phone held upright. + static func resolved(preferred: RowLayout?, widthClass: DuoWidthClass) -> RowLayout { + if let preferred { return preferred } + switch widthClass { + case .compact: return .cards + case .regular: return .grid + } + } + + var title: String { + switch self { + case .cards: return String(localized: "Cards") + case .grid: return String(localized: "Grid") + } + } + + var systemImage: String { + switch self { + case .cards: return "rectangle.grid.1x2" + case .grid: return "tablecells" + } + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Views/RowLayoutTests.swift b/apple/TableProMobile/TableProMobileTests/Views/RowLayoutTests.swift new file mode 100644 index 0000000000..d2a4a986b2 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Views/RowLayoutTests.swift @@ -0,0 +1,86 @@ +import Foundation +import SwiftUI +import TableProModels +@testable import TableProMobile +import Testing + +struct RowLayoutTests { + @Test("With no choice made, a compact width keeps the cards and a regular width opens the grid") + func defaultFollowsWidth() { + #expect(RowLayout.resolved(preferred: nil, widthClass: .compact) == .cards) + #expect(RowLayout.resolved(preferred: nil, widthClass: .regular) == .grid) + } + + @Test("A choice the user made wins at every width", arguments: RowLayout.allCases) + func choiceWinsAtEveryWidth(layout: RowLayout) { + for widthClass in [DuoWidthClass.compact, .regular] { + #expect(RowLayout.resolved(preferred: layout, widthClass: widthClass) == layout) + } + } + + @Test("A stored choice is read back by a later browser") + func storedChoiceIsRemembered() throws { + let suite = "RowLayoutTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let writer = AppStorage(RowLayout.storageKey, store: defaults) + #expect(writer.wrappedValue == nil) + writer.wrappedValue = .grid + + let reader = AppStorage(RowLayout.storageKey, store: defaults) + #expect(reader.wrappedValue == .grid) + #expect(defaults.string(forKey: RowLayout.storageKey) == "grid") + } + + @Test("An unknown stored value falls back to the width default") + func unknownStoredValueIsIgnored() throws { + let suite = "RowLayoutTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set("table", forKey: RowLayout.storageKey) + + let stored = AppStorage(RowLayout.storageKey, store: defaults).wrappedValue + #expect(stored == nil) + #expect(RowLayout.resolved(preferred: stored, widthClass: .compact) == .cards) + } +} + +struct RowGridMetricsTests { + private static func column(_ name: String) -> ColumnInfo { + ColumnInfo(name: name, typeName: "text") + } + + @Test("Every column gets a width, even when a row is shorter than the header") + func widthPerColumn() { + let columns = [Self.column("id"), Self.column("name"), Self.column("email")] + let widths = RowGridMetrics.columnWidths(columns: columns, rows: [["1"]], characterWidth: 8) + #expect(widths.count == 3) + } + + @Test("Widths stay between the minimum and the maximum") + func widthsAreClamped() { + let columns = [Self.column("id"), Self.column("body")] + let rows: [[String?]] = [["1", String(repeating: "x", count: 500)]] + let widths = RowGridMetrics.columnWidths(columns: columns, rows: rows, characterWidth: 8) + #expect(widths[0] == RowGridMetrics.minimumColumnWidth) + #expect(widths[1] == RowGridMetrics.maximumColumnWidth) + } + + @Test("A longer value widens its column, and NULL counts as its label") + func widthFollowsContent() { + let columns = [Self.column("a"), Self.column("b")] + let rows: [[String?]] = [["short value", nil], ["a much longer value here", nil]] + let widths = RowGridMetrics.columnWidths(columns: columns, rows: rows, characterWidth: 8) + #expect(widths[0] == CGFloat(24) * 8 + RowGridMetrics.cellHorizontalPadding * 2) + #expect(widths[1] == RowGridMetrics.minimumColumnWidth) + } + + @Test("Only the first line of a value counts toward the width") + func multilineValueUsesFirstLine() { + let columns = [Self.column("note")] + let rows: [[String?]] = [["first line\n" + String(repeating: "y", count: 60)]] + let widths = RowGridMetrics.columnWidths(columns: columns, rows: rows, characterWidth: 8) + #expect(widths[0] == CGFloat(10) * 8 + RowGridMetrics.cellHorizontalPadding * 2) + } +} From 0b6a9a107b93a6307255305572981125e1c2a424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= <26674308+J2TeamNNL@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:42:40 +0700 Subject: [PATCH 2/4] docs(changelog): add the iOS row grid entry --- apple/changelog.d/3963.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3963.added.md diff --git a/apple/changelog.d/3963.added.md b/apple/changelog.d/3963.added.md new file mode 100644 index 0000000000..f2e299921b --- /dev/null +++ b/apple/changelog.d/3963.added.md @@ -0,0 +1 @@ +iPhone and iPad show table rows in a grid with a pinned header and first column, beside the cards. From 2e2db2fba8be0acf7bd629c257b0eaf512cc34e9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 1 Nov 2026 12:23:29 +0700 Subject: [PATCH 3/4] feat(ios): rework the row grid and use it for query results --- .../Platform/AppPreferences.swift | 1 + .../Views/Components/RowCard.swift | 15 +- .../Views/Components/RowGrid.swift | 282 +++++++++++++----- .../Views/DataBrowserView.swift | 82 +++-- .../Views/QueryEditorView.swift | 40 ++- .../TableProMobile/Views/RowLayout.swift | 31 +- .../Views/RowGridTests.swift | 141 +++++++++ .../Views/RowLayoutTests.swift | 75 ++--- apple/changelog.d/3963.added.md | 2 +- 9 files changed, 491 insertions(+), 178 deletions(-) create mode 100644 apple/TableProMobile/TableProMobileTests/Views/RowGridTests.swift diff --git a/apple/TableProMobile/TableProMobile/Platform/AppPreferences.swift b/apple/TableProMobile/TableProMobile/Platform/AppPreferences.swift index b7dac65eb5..109d9da615 100644 --- a/apple/TableProMobile/TableProMobile/Platform/AppPreferences.swift +++ b/apple/TableProMobile/TableProMobile/Platform/AppPreferences.swift @@ -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] diff --git a/apple/TableProMobile/TableProMobile/Views/Components/RowCard.swift b/apple/TableProMobile/TableProMobile/Views/Components/RowCard.swift index 16d9a71b7f..ec6f1e6854 100644 --- a/apple/TableProMobile/TableProMobile/Views/Components/RowCard.swift +++ b/apple/TableProMobile/TableProMobile/Views/Components/RowCard.swift @@ -1,6 +1,12 @@ import SwiftUI import TableProModels +nonisolated enum RowKey { + static func columnIndex(columns: [ColumnInfo], primaryKeys: Set) -> Int? { + columns.firstIndex { primaryKeys.contains($0.name) } ?? (columns.isEmpty ? nil : 0) + } +} + struct RowCard: View { let columns: [ColumnInfo] let columnDetails: [ColumnInfo] @@ -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)] { diff --git a/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift b/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift index 7f9dc16b69..8155d1ffd0 100644 --- a/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift +++ b/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift @@ -1,119 +1,237 @@ import SwiftUI import TableProModels +import UIKit -nonisolated enum RowGridMetrics { - static let minimumColumnWidth: CGFloat = 72 - static let maximumColumnWidth: CGFloat = 320 - static let cellHorizontalPadding: CGFloat = 12 +nonisolated struct RowGridMetrics: Equatable, Sendable { + var minimumWidth: CGFloat + var maximumWidth: CGFloat + var horizontalPadding: CGFloat +} + +nonisolated struct RowGridLayout: Equatable, Sendable { static let sampledRowCount = 50 - static let maximumMeasuredCharacters = 40 + // Past this many characters even the narrowest glyphs overflow the widest column. + static let maximumMeasuredCharacters = 128 + + let order: [Int] + let widths: [CGFloat] - /// Sized from the header and the first rows only, so a page of 500 rows costs no more than one of 50. - static func columnWidths(columns: [ColumnInfo], rows: [[String?]], characterWidth: CGFloat) -> [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) - return columns.indices.map { index in - let longestValue = sample.reduce(0) { longest, row in - guard row.indices.contains(index) else { return longest } - return max(longest, displayLength(row[index] ?? "NULL")) + 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 characters = max(displayLength(columns[index].name), longestValue) - let width = CGFloat(characters) * characterWidth + cellHorizontalPadding * 2 - return min(max(width, minimumColumnWidth), maximumColumnWidth) + let width = widest.rounded(.up) + metrics.horizontalPadding * 2 + return min(max(width, metrics.minimumWidth), metrics.maximumWidth) } + return RowGridLayout(order: order, widths: widths) + } + + static func displayOrder(columnCount: Int, keyColumn: Int?) -> [Int] { + let columns = Array(0.. Int { - let firstLine = text.prefix { !$0.isNewline } - return min(firstLine.count, maximumMeasuredCharacters) + // 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[..: View { +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: View { let columns: [ColumnInfo] let rows: [[String?]] - @ViewBuilder let destination: (Int) -> Destination - @ViewBuilder let menuItems: (Int) -> MenuItems - let deleteAction: (Int) -> (() -> Void)? + let keyColumn: Int? + @ViewBuilder let row: (_ index: Int, _ line: RowGridLine) -> Row - @ScaledMetric(relativeTo: .subheadline) private var characterWidth: CGFloat = 8 - @State private var pinnedOffset: CGFloat = 0 + @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 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 widths = RowGridMetrics.columnWidths(columns: columns, rows: rows, characterWidth: characterWidth) ScrollView([.horizontal, .vertical]) { - LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { - ForEach(IndexedRow.wrap(rows)) { item in - rowLink(index: item.id, values: item.values, widths: widths) - } - } header: { - header(widths: widths) - } + // For one pass after the columns change, the layout still describes the old ones. + if let layout, layout.order.count == columns.count { + grid(layout, width: max(layout.totalWidth, viewport.width)) } } - // The first column rides the horizontal offset back into view, so a row keeps its name while the rest scrolls. - .onScrollGeometryChange(for: CGFloat.self) { geometry in - max(geometry.contentOffset.x + geometry.contentInsets.leading, 0) - } action: { _, offset in - pinnedOffset = offset + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + .defaultScrollAnchor(.topLeading, for: .alignment) + .buttonStyle(.plain) + .onGeometryChange(for: RowGridViewport.self) { proxy in + RowGridViewport( + width: proxy.size.width - proxy.safeAreaInsets.leading - proxy.safeAreaInsets.trailing, + leadingInset: proxy.safeAreaInsets.leading + ) + } action: { viewport = $0 } + // Reads only the key: the closure's other captures belong to the previous body. + .onChange(of: layoutKey, initial: true) { _, key in + let measure = RowGridTextMeasure(dynamicTypeSize: key.dynamicTypeSize) + layout = 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(widths: [CGFloat]) -> some View { + private func header(_ layout: RowGridLayout, width: CGFloat, style: RowGridLine.Style) -> some View { HStack(spacing: 0) { - ForEach(Array(columns.enumerated()), id: \.offset) { index, column in - Text(column.name) + 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, RowGridMetrics.cellHorizontalPadding) - .padding(.vertical, 8) - .frame(width: widths[index], alignment: .leading) - .modifier(PinnedCell(isPinned: index == 0, offset: pinnedOffset)) + .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) { Divider() } + .overlay(alignment: .bottom) { + Rectangle().fill(Color(.separator)).frame(height: style.hairline) + } .accessibilityElement(children: .combine) .accessibilityAddTraits(.isHeader) } - private func rowLink(index: Int, values: [String?], widths: [CGFloat]) -> some View { - let delete = deleteAction(index) - return NavigationLink { - destination(index) - } label: { - line(values: values, widths: widths) - } - .buttonStyle(.plain) - .hoverEffect() - .contextMenu { - menuItems(index) - if let delete { - Divider() - Button("Delete Row", systemImage: "trash", role: .destructive, action: delete) - } - } - .accessibilityActions { - if let delete { - Button("Delete row", action: delete) - } - } + private struct LayoutKey: Equatable { + let columnNames: [String] + let sample: ArraySlice<[String?]> + let keyColumn: Int? + let dynamicTypeSize: DynamicTypeSize + let metrics: RowGridMetrics } +} - private func line(values: [String?], widths: [CGFloat]) -> some View { +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(Array(columns.indices), id: \.self) { index in - let value = values.indices.contains(index) ? values[index] : nil - cell(value) - .frame(width: widths[index], alignment: .leading) - .modifier(PinnedCell(isPinned: index == 0, offset: pinnedOffset)) + 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()) - .overlay(alignment: .bottom) { Divider() } - .accessibilityElement(children: .combine) + .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 { @@ -127,23 +245,27 @@ struct RowGrid: View { } .font(.subheadline) .lineLimit(1) - .padding(.horizontal, RowGridMetrics.cellHorizontalPadding) - .padding(.vertical, 11) + .padding(.horizontal, style.horizontalPadding) + .padding(.vertical, style.verticalPadding) } } -private struct PinnedCell: ViewModifier { +private struct RowGridCell: ViewModifier { let isPinned: Bool - let offset: CGFloat + 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 .background(Color(.systemBackground)) .overlay(alignment: .trailing) { - if offset > 0 { Divider() } + Rectangle().fill(Color(.separator)).frame(width: style.hairline) + } + .visualEffect { content, proxy in + content.offset(x: max(0, inset - proxy.frame(in: .scrollView).minX)) } - .offset(x: offset) .zIndex(1) } else { content diff --git a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 01196f2d6a..f9e2336d32 100644 --- a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -37,7 +37,7 @@ struct DataBrowserView: View { @State private var isCompletingCells = false @State private var hapticSuccess = false @State private var hapticError = false - @AppStorage(RowLayout.storageKey) private var preferredLayout: RowLayout? + @AppStorage(AppPreferences.rowLayoutKey) private var preferredLayout: RowLayout? private var activeSchema: String? { coordinator.supportsSchemas ? coordinator.activeSchema : nil @@ -305,14 +305,7 @@ struct DataBrowserView: View { } private var rowLayout: RowLayout { - RowLayout.resolved(preferred: preferredLayout, widthClass: duoWidthClass) - } - - private var rowLayoutBinding: Binding { - Binding( - get: { rowLayout }, - set: { preferredLayout = $0 } - ) + RowLayout.resolved(preferred: preferredLayout, idiom: UIDevice.current.userInterfaceIdiom) } @ViewBuilder @@ -336,16 +329,37 @@ struct DataBrowserView: View { } private var rowGrid: some View { - RowGrid( + let primaryKeys = primaryKeyNames + return RowGrid( columns: columns, rows: rows, - destination: { index in rowDetail(index: index) }, - menuItems: { index in rowContextMenu(cells: viewModel.cells(at: index)) }, - deleteAction: { index in - guard canDeleteRows, let locator = viewModel.rowLocator(forRowAt: index) else { return nil } - return { confirmDelete(locator) } + keyColumn: RowKey.columnIndex(columns: columns, primaryKeys: primaryKeys) + ) { index, line in + NavigationLink { + rowDetail(index: index) + } label: { + line + } + .hoverEffect() + .contextMenu { + rowContextMenu(index: index) + } preview: { + // The row itself can be thousands of points wide; the lift preview shows its card. + RowCard( + columns: columns, + columnDetails: viewModel.columnDetails, + row: rows.indices.contains(index) ? rows[index] : [], + previewFieldCount: DuoLayoutResolver.regularPreviewFieldCount + ) + .padding() + .frame(maxWidth: 360, alignment: .leading) } - ) + .accessibilityActions { deleteAccessibilityAction(index: index) } + } + } + + private var primaryKeyNames: Set { + Set(viewModel.columnDetails.filter(\.isPrimaryKey).map(\.name)) } private func pageContainer(_ rows: some View) -> some View { @@ -390,8 +404,7 @@ struct DataBrowserView: View { } private func rowLink(index: Int, row: [String?]) -> some View { - let cells = viewModel.cells(at: index) - let deleteKeys = canDeleteRows ? viewModel.rowLocator(forRowAt: index) : nil + let deleteKeys = deletableRow(at: index) return NavigationLink { rowDetail(index: index) } label: { @@ -403,7 +416,7 @@ struct DataBrowserView: View { ) } .hoverEffect() - .contextMenu { rowContextMenu(cells: cells) } + .contextMenu { rowContextMenu(index: index) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { if let deleteKeys { Button { @@ -414,10 +427,17 @@ struct DataBrowserView: View { .tint(.red) } } - .accessibilityActions { - if let deleteKeys { - Button("Delete row") { confirmDelete(deleteKeys) } - } + .accessibilityActions { deleteAccessibilityAction(index: index) } + } + + private func deletableRow(at index: Int) -> RowLocator? { + canDeleteRows ? viewModel.rowLocator(forRowAt: index) : nil + } + + @ViewBuilder + private func deleteAccessibilityAction(index: Int) -> some View { + if let locator = deletableRow(at: index) { + Button("Delete row") { confirmDelete(locator) } } } @@ -427,7 +447,8 @@ struct DataBrowserView: View { } @ViewBuilder - private func rowContextMenu(cells: [Cell]) -> some View { + private func rowContextMenu(index: Int) -> some View { + let cells = viewModel.cells(at: index) if DataBrowserViewModel.canComplete(cells) { Menu("Share Row") { ForEach(ExportFormat.offered(for: connection.type)) { format in @@ -474,6 +495,10 @@ struct DataBrowserView: View { } } } + if let locator = deletableRow(at: index) { + Divider() + Button("Delete Row", systemImage: "trash", role: .destructive) { confirmDelete(locator) } + } } @ToolbarContentBuilder @@ -489,14 +514,7 @@ struct DataBrowserView: View { } Divider() } - Picker(selection: rowLayoutBinding) { - ForEach(RowLayout.allCases) { layout in - Label(layout.title, systemImage: layout.systemImage).tag(layout) - } - } label: { - Label("Layout", systemImage: rowLayout.systemImage) - } - .pickerStyle(.inline) + RowLayoutPicker() Divider() Section("Export") { ForEach(ExportFormat.offered(for: connection.type)) { format in diff --git a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift index c63ee96275..c3c8841ab8 100644 --- a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -50,6 +50,7 @@ struct QueryEditorView: View { @State private var containerSize: CGSize = .zero @State private var savedQueriesRoute: SavedQueriesSheetRoute? @State private var savedQueryFolderExpansion: Set = [] + @AppStorage(AppPreferences.rowLayoutKey) private var preferredLayout: RowLayout? private var session: ConnectionSession? { coordinator.session } private var tables: [TableInfo] { coordinator.tables } @@ -427,7 +428,7 @@ struct QueryEditorView: View { } else { VStack(spacing: 0) { resultNotices(includesTruncation: true) - resultList + resultRows } } } @@ -541,6 +542,42 @@ struct QueryEditorView: View { .background(.thinMaterial) } + private var rowLayout: RowLayout { + RowLayout.resolved(preferred: preferredLayout, idiom: UIDevice.current.userInterfaceIdiom) + } + + @ViewBuilder + private var resultRows: some View { + switch rowLayout { + case .cards: resultList + case .grid: resultGrid + } + } + + private var resultGrid: some View { + let columns = viewModel.columns + let rows = viewModel.legacyRows + return RowGrid( + columns: columns, + rows: rows, + keyColumn: RowKey.columnIndex(columns: columns, primaryKeys: []) + ) { index, line in + NavigationLink { + RowDetailView(columns: columns, rows: viewModel.window.rows, initialIndex: index) + } label: { + line + } + .hoverEffect() + .contextMenu { + resultRowContextMenu(columns: columns, rowIndex: index) + } preview: { + resultRowCard(columns: columns, row: rows.indices.contains(index) ? rows[index] : []) + .padding() + .frame(maxWidth: 360, alignment: .leading) + } + } + } + private var resultList: some View { let indexed = IndexedRow.wrap(viewModel.legacyRows) return List { @@ -665,6 +702,7 @@ struct QueryEditorView: View { } if !viewModel.legacyRows.isEmpty { + RowLayoutPicker() Section("Share Results") { ForEach(ExportFormat.offered(for: databaseType)) { format in Button { diff --git a/apple/TableProMobile/TableProMobile/Views/RowLayout.swift b/apple/TableProMobile/TableProMobile/Views/RowLayout.swift index 4c1ad11de8..a6df08d533 100644 --- a/apple/TableProMobile/TableProMobile/Views/RowLayout.swift +++ b/apple/TableProMobile/TableProMobile/Views/RowLayout.swift @@ -1,21 +1,16 @@ -import Foundation +import SwiftUI +import UIKit nonisolated enum RowLayout: String, CaseIterable, Identifiable, Sendable { case cards case grid - static let storageKey = "com.TablePro.settings.rowLayout" - var id: String { rawValue } - /// A choice the user made wins at every width; until then a regular width opens in the grid - /// and a compact one keeps the cards, which fit a phone held upright. - static func resolved(preferred: RowLayout?, widthClass: DuoWidthClass) -> RowLayout { + // The idiom, not the size class: a resize or rotation must not swap the layout under a pushed row. + static func resolved(preferred: RowLayout?, idiom: UIUserInterfaceIdiom) -> RowLayout { if let preferred { return preferred } - switch widthClass { - case .compact: return .cards - case .regular: return .grid - } + return idiom == .phone ? .cards : .grid } var title: String { @@ -32,3 +27,19 @@ nonisolated enum RowLayout: String, CaseIterable, Identifiable, Sendable { } } } + +struct RowLayoutPicker: View { + @AppStorage(AppPreferences.rowLayoutKey) private var preferredLayout: RowLayout? + + var body: some View { + let current = RowLayout.resolved(preferred: preferredLayout, idiom: UIDevice.current.userInterfaceIdiom) + Picker(selection: Binding(get: { current }, set: { preferredLayout = $0 })) { + ForEach(RowLayout.allCases) { layout in + Label(layout.title, systemImage: layout.systemImage).tag(layout) + } + } label: { + Label("Layout", systemImage: current.systemImage) + } + .pickerStyle(.inline) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Views/RowGridTests.swift b/apple/TableProMobile/TableProMobileTests/Views/RowGridTests.swift new file mode 100644 index 0000000000..bccfd20232 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Views/RowGridTests.swift @@ -0,0 +1,141 @@ +import CoreGraphics +import SwiftUI +@testable import TableProMobile +import Testing +import UIKit + +struct RowGridLayoutTests { + private static let metrics = RowGridMetrics(minimumWidth: 72, maximumWidth: 320, horizontalPadding: 12) + + private static func layout( + _ names: [String], + _ rows: [[String?]], + keyColumn: Int? = 0, + measure: (String, Bool) -> CGFloat = { text, _ in CGFloat(text.count) * 8 } + ) -> RowGridLayout { + RowGridLayout.make(columnNames: names, rows: rows, keyColumn: keyColumn, metrics: metrics, measure: measure) + } + + @Test("The key column leads and the rest keep table order") + func keyColumnLeads() { + #expect(RowGridLayout.displayOrder(columnCount: 4, keyColumn: 2) == [2, 0, 1, 3]) + #expect(RowGridLayout.displayOrder(columnCount: 4, keyColumn: 0) == [0, 1, 2, 3]) + } + + @Test("A missing or out of range key column keeps table order") + func noKeyKeepsOrder() { + #expect(RowGridLayout.displayOrder(columnCount: 3, keyColumn: nil) == [0, 1, 2]) + #expect(RowGridLayout.displayOrder(columnCount: 3, keyColumn: 5) == [0, 1, 2]) + #expect(RowGridLayout.displayOrder(columnCount: 0, keyColumn: 0).isEmpty) + } + + @Test("Widths follow the display order, one per column, and a short row counts as NULL") + func widthPerDisplayedColumn() { + let layout = Self.layout(["a", "bbbbbbbbbbbbbbbbbbbb"], [["1"]], keyColumn: 1) + #expect(layout.order == [1, 0]) + #expect(layout.widths == [20 * 8 + 24, 72]) + #expect(layout.totalWidth == CGFloat(20 * 8 + 24 + 72)) + } + + @Test("Widths stay between the minimum and the maximum") + func widthsAreClamped() { + let layout = Self.layout(["id", "body"], [["1", String(repeating: "x", count: 500)]]) + #expect(layout.widths == [72, 320]) + } + + @Test("The header is measured as a header and values as cells") + func headerMeasuredAsHeader() { + var measured: [String: Bool] = [:] + _ = Self.layout(["name"], [["value"]]) { text, isHeader in + measured[text] = isHeader + return 0 + } + #expect(measured == ["name": true, "value": false]) + } + + @Test("A wider measurement widens the column even with fewer characters") + func widthFollowsMeasurement() { + let layout = Self.layout(["c"], [["abcdefghij"], ["日本語"]]) { text, _ in + text == "日本語" ? 200 : CGFloat(text.count) * 8 + } + #expect(layout.widths == [200 + 24]) + } + + @Test("Only the first rows are sampled") + func onlyFirstRowsSampled() { + var rows: [[String?]] = Array(repeating: ["x"], count: RowGridLayout.sampledRowCount) + rows.append([String(repeating: "y", count: 30)]) + #expect(Self.layout(["c"], rows).widths == [72]) + } + + @Test("A column already at the maximum stops measuring") + func stopsAtMaximum() { + var calls = 0 + let rows: [[String?]] = Array(repeating: [String(repeating: "z", count: 100)], count: 10) + _ = Self.layout(["c"], rows) { text, _ in + calls += 1 + return CGFloat(text.count) * 8 + } + #expect(calls == 2) + } + + @Test("Only the first line counts, with room for the ellipsis that marks the rest") + func firstLineWithEllipsis() { + #expect(RowGridLayout.measuredText("first line\nsecond") == "first line…") + #expect(RowGridLayout.measuredText("one line") == "one line") + #expect(RowGridLayout.measuredText("") == "") + } + + @Test("A long value is measured up to the cap, not to its end") + func longValueCapped() { + let text = String(repeating: "a", count: 4_096) + let measured = RowGridLayout.measuredText(text) + #expect(measured.count == RowGridLayout.maximumMeasuredCharacters + 1) + #expect(measured.hasSuffix("…")) + } +} + +@MainActor +struct RowGridTextMeasureTests { + private static let samples: [(text: String, isHeader: Bool)] = [ + ("11170334", false), + ("2026-11-01 09:41:00", false), + ("WWWWWWWWWW", false), + ("For Those About To Rock (We Salute You)", false), + ("日本語のテキストと中文", false), + ("Ünïcødé ẞtraße", false), + ("NULL", false), + ("AlbumId", true), + ("UnitPrice", true), + ("MediaTypeId", true), + ] + + private static func renderedWidth(_ text: String, isHeader: Bool, size: DynamicTypeSize) -> CGFloat { + let view = Text(verbatim: text) + .font(.subheadline) + .fontWeight(isHeader ? .semibold : nil) + .lineLimit(1) + .fixedSize() + .environment(\.dynamicTypeSize, size) + return UIHostingController(rootView: view) + .sizeThatFits(in: CGSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude)) + .width + } + + @Test("A measured width holds the text as SwiftUI renders it", arguments: [DynamicTypeSize.large, .xxxLarge, .accessibility3]) + func measuredWidthHoldsRenderedText(size: DynamicTypeSize) { + let measure = RowGridTextMeasure(dynamicTypeSize: size) + for sample in Self.samples { + let measured = measure.width(of: sample.text, isHeader: sample.isHeader).rounded(.up) + let rendered = Self.renderedWidth(sample.text, isHeader: sample.isHeader, size: size) + #expect(measured >= rendered, "\(sample.text) at \(size): measured \(measured), rendered \(rendered)") + } + } + + @Test("Larger text measures wider") + func scalesWithDynamicType() { + let regular = RowGridTextMeasure(dynamicTypeSize: .large).width(of: "AlbumId", isHeader: false) + let large = RowGridTextMeasure(dynamicTypeSize: .accessibility3).width(of: "AlbumId", isHeader: false) + #expect(large > regular) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Views/RowLayoutTests.swift b/apple/TableProMobile/TableProMobileTests/Views/RowLayoutTests.swift index d2a4a986b2..d855ff4d13 100644 --- a/apple/TableProMobile/TableProMobileTests/Views/RowLayoutTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Views/RowLayoutTests.swift @@ -3,18 +3,20 @@ import SwiftUI import TableProModels @testable import TableProMobile import Testing +import UIKit struct RowLayoutTests { - @Test("With no choice made, a compact width keeps the cards and a regular width opens the grid") - func defaultFollowsWidth() { - #expect(RowLayout.resolved(preferred: nil, widthClass: .compact) == .cards) - #expect(RowLayout.resolved(preferred: nil, widthClass: .regular) == .grid) + @Test("With no choice made, iPhone keeps the cards and every other device opens the grid") + func defaultFollowsIdiom() { + #expect(RowLayout.resolved(preferred: nil, idiom: .phone) == .cards) + #expect(RowLayout.resolved(preferred: nil, idiom: .pad) == .grid) + #expect(RowLayout.resolved(preferred: nil, idiom: .mac) == .grid) } - @Test("A choice the user made wins at every width", arguments: RowLayout.allCases) - func choiceWinsAtEveryWidth(layout: RowLayout) { - for widthClass in [DuoWidthClass.compact, .regular] { - #expect(RowLayout.resolved(preferred: layout, widthClass: widthClass) == layout) + @Test("A choice the user made wins on every device", arguments: RowLayout.allCases) + func choiceWinsOnEveryDevice(layout: RowLayout) { + for idiom in [UIUserInterfaceIdiom.phone, .pad, .mac] { + #expect(RowLayout.resolved(preferred: layout, idiom: idiom) == layout) } } @@ -24,63 +26,40 @@ struct RowLayoutTests { let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } - let writer = AppStorage(RowLayout.storageKey, store: defaults) + let writer = AppStorage(AppPreferences.rowLayoutKey, store: defaults) #expect(writer.wrappedValue == nil) writer.wrappedValue = .grid - let reader = AppStorage(RowLayout.storageKey, store: defaults) + let reader = AppStorage(AppPreferences.rowLayoutKey, store: defaults) #expect(reader.wrappedValue == .grid) - #expect(defaults.string(forKey: RowLayout.storageKey) == "grid") + #expect(defaults.string(forKey: AppPreferences.rowLayoutKey) == "grid") } - @Test("An unknown stored value falls back to the width default") + @Test("An unknown stored value falls back to the device default") func unknownStoredValueIsIgnored() throws { let suite = "RowLayoutTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } - defaults.set("table", forKey: RowLayout.storageKey) + defaults.set("table", forKey: AppPreferences.rowLayoutKey) - let stored = AppStorage(RowLayout.storageKey, store: defaults).wrappedValue + let stored = AppStorage(AppPreferences.rowLayoutKey, store: defaults).wrappedValue #expect(stored == nil) - #expect(RowLayout.resolved(preferred: stored, widthClass: .compact) == .cards) + #expect(RowLayout.resolved(preferred: stored, idiom: .phone) == .cards) } } -struct RowGridMetricsTests { - private static func column(_ name: String) -> ColumnInfo { - ColumnInfo(name: name, typeName: "text") - } - - @Test("Every column gets a width, even when a row is shorter than the header") - func widthPerColumn() { - let columns = [Self.column("id"), Self.column("name"), Self.column("email")] - let widths = RowGridMetrics.columnWidths(columns: columns, rows: [["1"]], characterWidth: 8) - #expect(widths.count == 3) - } - - @Test("Widths stay between the minimum and the maximum") - func widthsAreClamped() { - let columns = [Self.column("id"), Self.column("body")] - let rows: [[String?]] = [["1", String(repeating: "x", count: 500)]] - let widths = RowGridMetrics.columnWidths(columns: columns, rows: rows, characterWidth: 8) - #expect(widths[0] == RowGridMetrics.minimumColumnWidth) - #expect(widths[1] == RowGridMetrics.maximumColumnWidth) - } +struct RowKeyTests { + private static let columns = ["created_at", "name", "id", "code"].map { ColumnInfo(name: $0, typeName: "text") } - @Test("A longer value widens its column, and NULL counts as its label") - func widthFollowsContent() { - let columns = [Self.column("a"), Self.column("b")] - let rows: [[String?]] = [["short value", nil], ["a much longer value here", nil]] - let widths = RowGridMetrics.columnWidths(columns: columns, rows: rows, characterWidth: 8) - #expect(widths[0] == CGFloat(24) * 8 + RowGridMetrics.cellHorizontalPadding * 2) - #expect(widths[1] == RowGridMetrics.minimumColumnWidth) + @Test("The first primary key column in table order names the row") + func primaryKeyNamesTheRow() { + #expect(RowKey.columnIndex(columns: Self.columns, primaryKeys: ["id"]) == 2) + #expect(RowKey.columnIndex(columns: Self.columns, primaryKeys: ["code", "id"]) == 2) } - @Test("Only the first line of a value counts toward the width") - func multilineValueUsesFirstLine() { - let columns = [Self.column("note")] - let rows: [[String?]] = [["first line\n" + String(repeating: "y", count: 60)]] - let widths = RowGridMetrics.columnWidths(columns: columns, rows: rows, characterWidth: 8) - #expect(widths[0] == CGFloat(10) * 8 + RowGridMetrics.cellHorizontalPadding * 2) + @Test("Without a primary key the first column names the row, and no columns name nothing") + func firstColumnWithoutKey() { + #expect(RowKey.columnIndex(columns: Self.columns, primaryKeys: []) == 0) + #expect(RowKey.columnIndex(columns: [], primaryKeys: ["id"]) == nil) } } diff --git a/apple/changelog.d/3963.added.md b/apple/changelog.d/3963.added.md index f2e299921b..552d0db539 100644 --- a/apple/changelog.d/3963.added.md +++ b/apple/changelog.d/3963.added.md @@ -1 +1 @@ -iPhone and iPad show table rows in a grid with a pinned header and first column, beside the cards. +iPhone and iPad show table rows and query results in a grid with a pinned header and key column, beside the cards. From d293543880cbbcff0cc9c362b3643baa11f2f296 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 1 Nov 2026 13:00:34 +0700 Subject: [PATCH 4/4] fix(ios): size the grid's key column from the visible width and pin it at the safe edge --- .../Views/Components/RowGrid.swift | 62 ++++++++++++------- .../Views/RowGridTests.swift | 12 ++++ 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift b/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift index 8155d1ffd0..90f1c2adea 100644 --- a/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift +++ b/apple/TableProMobile/TableProMobile/Views/Components/RowGrid.swift @@ -40,6 +40,15 @@ nonisolated struct RowGridLayout: Equatable, Sendable { 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..: View { @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 layout: RowGridLayout? + @State private var computedLayout: (key: LayoutKey, layout: RowGridLayout)? @State private var viewport = RowGridViewport(width: 0, leadingInset: 0) private var layoutKey: LayoutKey { @@ -116,34 +125,44 @@ struct RowGrid: View { } var body: some View { + let layout = currentLayout.limitingKeyColumn(toViewportWidth: viewport.width) ScrollView([.horizontal, .vertical]) { - // For one pass after the columns change, the layout still describes the old ones. - if let layout, layout.order.count == columns.count { - grid(layout, width: max(layout.totalWidth, viewport.width)) - } + grid(layout, width: max(layout.totalWidth, viewport.width)) } .scrollBounceBehavior(.basedOnSize, axes: .horizontal) .defaultScrollAnchor(.topLeading, for: .alignment) .buttonStyle(.plain) - .onGeometryChange(for: RowGridViewport.self) { proxy in - RowGridViewport( - width: proxy.size.width - proxy.safeAreaInsets.leading - proxy.safeAreaInsets.trailing, - leadingInset: proxy.safeAreaInsets.leading - ) - } action: { viewport = $0 } + // 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 - let measure = RowGridTextMeasure(dynamicTypeSize: key.dynamicTypeSize) - layout = RowGridLayout.make( - columnNames: key.columnNames, - rows: key.sample, - keyColumn: key.keyColumn, - metrics: key.metrics, - measure: measure.width(of:isHeader:) - ) + 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]) { @@ -259,12 +278,13 @@ private struct RowGridCell: ViewModifier { let inset = style.pinnedInset // Offset in the render pass, so a sideways scroll moves no state and rebuilds no row. content - .background(Color(.systemBackground)) + // 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, inset - proxy.frame(in: .scrollView).minX)) + content.offset(x: max(0, -proxy.frame(in: .scrollView).minX)) } .zIndex(1) } else { diff --git a/apple/TableProMobile/TableProMobileTests/Views/RowGridTests.swift b/apple/TableProMobile/TableProMobileTests/Views/RowGridTests.swift index bccfd20232..7d514acbea 100644 --- a/apple/TableProMobile/TableProMobileTests/Views/RowGridTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Views/RowGridTests.swift @@ -79,6 +79,15 @@ struct RowGridLayoutTests { #expect(calls == 2) } + @Test("The key column takes at most its share of the viewport, and only the key column shrinks") + func keyColumnLimitedByViewport() { + let layout = RowGridLayout(order: [0, 1], widths: [300, 200]) + #expect(layout.limitingKeyColumn(toViewportWidth: 400).widths == [160, 200]) + #expect(layout.limitingKeyColumn(toViewportWidth: 1_000).widths == [300, 200]) + #expect(layout.limitingKeyColumn(toViewportWidth: 0).widths == [300, 200]) + #expect(RowGridLayout(order: [], widths: []).limitingKeyColumn(toViewportWidth: 400).widths.isEmpty) + } + @Test("Only the first line counts, with room for the ellipsis that marks the rest") func firstLineWithEllipsis() { #expect(RowGridLayout.measuredText("first line\nsecond") == "first line…") @@ -105,6 +114,9 @@ struct RowGridTextMeasureTests { ("日本語のテキストと中文", false), ("Ünïcødé ẞtraße", false), ("NULL", false), + ("TrackId", true), + ("PlaylistId", true), + ("InvoiceLineId", true), ("AlbumId", true), ("UnitPrice", true), ("MediaTypeId", true),