diff --git a/apps/native/Sources/VisionNativeUI/AccountDetailView.swift b/apps/native/Sources/VisionNativeUI/AccountDetailView.swift
index 918af9c8a..1b76ae40c 100644
--- a/apps/native/Sources/VisionNativeUI/AccountDetailView.swift
+++ b/apps/native/Sources/VisionNativeUI/AccountDetailView.swift
@@ -336,27 +336,29 @@ struct AccountDetailContent: View {
SkeletonBar(width: nil, height: 40)
.accessibilityLabel(strings.t("common.loading"))
} else {
- Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
- GridRow {
- Text(strings.t("txPage.col.date"))
- Text(strings.t("txPage.field.description"))
- .gridColumnAlignment(.leading)
- Text(strings.t("txPage.col.amount"))
- .gridColumnAlignment(.trailing)
- }
- .font(.footnote.weight(.medium))
- .foregroundStyle(theme.secondaryLabel)
- ForEach(model.plannedRows) { row in
- Divider()
+ HorizontalOverflowScroll {
+ Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
GridRow {
- Text(VisionFormat.appDate(row.date, format: settings.dateFormat))
- .monospacedDigit()
- Text(row.title ?? strings.t("accounts.detail.unlabelled"))
- .lineLimit(1)
- .frame(maxWidth: .infinity, alignment: .leading)
- MoneyText(
- amount: row.amount, currency: (row.currency ?? account.currency), settings: settings,
- signed: true)
+ Text(strings.t("txPage.col.date"))
+ Text(strings.t("txPage.field.description"))
+ .gridColumnAlignment(.leading)
+ Text(strings.t("txPage.col.amount"))
+ .gridColumnAlignment(.trailing)
+ }
+ .font(.footnote.weight(.medium))
+ .foregroundStyle(theme.secondaryLabel)
+ ForEach(model.plannedRows) { row in
+ Divider()
+ GridRow {
+ Text(VisionFormat.appDate(row.date, format: settings.dateFormat))
+ .monospacedDigit()
+ Text(row.title ?? strings.t("accounts.detail.unlabelled"))
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ MoneyText(
+ amount: row.amount, currency: (row.currency ?? account.currency), settings: settings,
+ signed: true)
+ }
}
}
}
@@ -499,46 +501,48 @@ struct AccountLedgerCard: View {
}
private var table: some View {
- Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
- GridRow {
- Text(strings.t("txPage.col.date"))
- Text(strings.t("txPage.field.description"))
- Text(strings.t("txPage.col.category"))
- Text(strings.t("txPage.col.amount"))
- .gridColumnAlignment(.trailing)
- Text(strings.t("txPage.field.balance"))
- .gridColumnAlignment(.trailing)
- }
- .font(.footnote.weight(.medium))
- .foregroundStyle(theme.secondaryLabel)
- ForEach(model.visibleLedger) { row in
- Divider()
+ HorizontalOverflowScroll {
+ Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
GridRow {
- Text(VisionFormat.appDate(row.date, format: settings.dateFormat))
- .monospacedDigit()
- VStack(alignment: .leading, spacing: 1) {
- Text(row.title ?? strings.t("accounts.detail.unlabelled"))
- .lineLimit(1)
- if let memo = row.subtitle {
- Text(memo)
- .font(.footnote)
- .foregroundStyle(theme.secondaryLabel)
+ Text(strings.t("txPage.col.date"))
+ Text(strings.t("txPage.field.description"))
+ Text(strings.t("txPage.col.category"))
+ Text(strings.t("txPage.col.amount"))
+ .gridColumnAlignment(.trailing)
+ Text(strings.t("txPage.field.balance"))
+ .gridColumnAlignment(.trailing)
+ }
+ .font(.footnote.weight(.medium))
+ .foregroundStyle(theme.secondaryLabel)
+ ForEach(model.visibleLedger) { row in
+ Divider()
+ GridRow {
+ Text(VisionFormat.appDate(row.date, format: settings.dateFormat))
+ .monospacedDigit()
+ VStack(alignment: .leading, spacing: 1) {
+ Text(row.title ?? strings.t("accounts.detail.unlabelled"))
.lineLimit(1)
+ if let memo = row.subtitle {
+ Text(memo)
+ .font(.footnote)
+ .foregroundStyle(theme.secondaryLabel)
+ .lineLimit(1)
+ }
}
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- Text(row.categoryName ?? "—")
- .foregroundStyle(theme.secondaryLabel)
- .lineLimit(1)
- Text(MoneyText.text(row.amount, currency: row.currency, settings: settings, signed: true))
- .monospacedDigit()
- .foregroundStyle(row.amount < 0 ? theme.loss : (row.amount > 0 ? theme.gain : theme.secondaryLabel))
- if let balance = row.runningBalance {
- MoneyText(amount: balance, currency: row.currency, settings: settings, signed: false, colored: false)
- .foregroundStyle(theme.secondaryLabel)
- } else {
- Text(verbatim: "—")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Text(row.categoryName ?? "—")
.foregroundStyle(theme.secondaryLabel)
+ .lineLimit(1)
+ Text(MoneyText.text(row.amount, currency: row.currency, settings: settings, signed: true))
+ .monospacedDigit()
+ .foregroundStyle(row.amount < 0 ? theme.loss : (row.amount > 0 ? theme.gain : theme.secondaryLabel))
+ if let balance = row.runningBalance {
+ MoneyText(amount: balance, currency: row.currency, settings: settings, signed: false, colored: false)
+ .foregroundStyle(theme.secondaryLabel)
+ } else {
+ Text(verbatim: "—")
+ .foregroundStyle(theme.secondaryLabel)
+ }
}
}
}
diff --git a/apps/native/Sources/VisionNativeUI/AccountsView.swift b/apps/native/Sources/VisionNativeUI/AccountsView.swift
index 7f0bae275..a9294e9a3 100644
--- a/apps/native/Sources/VisionNativeUI/AccountsView.swift
+++ b/apps/native/Sources/VisionNativeUI/AccountsView.swift
@@ -267,8 +267,9 @@ struct AccountHubRow: View {
private var titleLine: some View {
HStack(spacing: 6) {
+ // ListRow's title: `type-body` (Inter 14, regular), not medium.
Text(label)
- .font(.body.weight(.medium))
+ .font(VisionFonts.font(DesignTokens.Typography.body, relativeTo: .body))
.lineLimit(1)
if !account.isActive {
VisionBadge(text: strings.t("accounts.archived"), tone: .outline, small: true)
@@ -279,13 +280,16 @@ struct AccountHubRow: View {
}
}
+ /// ListRow's subtitle line: `type-footnote` (Inter 12).
+ private static var footnote: Font { VisionFonts.font(DesignTokens.Typography.footnote, relativeTo: .footnote) }
+
@ViewBuilder
private var subtitle: some View {
if let drift = model.drift(account) {
AccountDriftChip(drift: drift, strings: strings) { model.openReconcile(account) }
}
Text(model.meta(account))
- .font(.footnote)
+ .font(Self.footnote)
.foregroundStyle(theme.secondaryLabel)
.lineLimit(1)
if isPortfolio {
@@ -297,26 +301,26 @@ struct AccountHubRow: View {
Text(verbatim: "· \(provenance)")
}
}
- .font(.footnote)
+ .font(Self.footnote)
.foregroundStyle(theme.secondaryLabel)
.lineLimit(1)
}
} else if account.computedBalance != nil, let provenance = model.provenance(account) {
Text(provenance)
- .font(.footnote)
+ .font(Self.footnote)
.foregroundStyle(theme.secondaryLabel)
.lineLimit(1)
}
if !isPortfolio && account.balanceIncomplete == true {
Text(strings.t("accounts.balanceIncomplete"))
- .font(.footnote)
+ .font(Self.footnote)
.foregroundStyle(theme.warning)
ForEach(model.excludedParts(account), id: \.currency) { part in
HStack(spacing: 4) {
MoneyText(amount: part.balance, currency: part.currency, settings: settings, signed: false, colored: false)
Text(strings.t("accounts.balanceExcluded"))
}
- .font(.footnote)
+ .font(Self.footnote)
.foregroundStyle(theme.warning)
}
}
@@ -410,7 +414,8 @@ struct AccountDriftChip: View {
var body: some View {
Button(action: open) {
- VisionBadge(text: drift.label, tone: drift.stale ? .warning : .destructive, small: true)
+ // `badgeVariants({ variant })` without a size: the default `px-2.5`, as on the web.
+ VisionBadge(text: drift.label, tone: drift.stale ? .warning : .destructive)
}
.buttonStyle(.plain)
.visionTooltip(strings.t(drift.tooltipKey))
diff --git a/apps/native/Sources/VisionNativeUI/AnalysisWorkbenchReportView.swift b/apps/native/Sources/VisionNativeUI/AnalysisWorkbenchReportView.swift
index 9ab50e02f..d8c74fba7 100644
--- a/apps/native/Sources/VisionNativeUI/AnalysisWorkbenchReportView.swift
+++ b/apps/native/Sources/VisionNativeUI/AnalysisWorkbenchReportView.swift
@@ -14,11 +14,13 @@ struct AnalysisWorkbenchReportView: View {
VStack(alignment: .leading, spacing: 12) {
errors(raw["errors"]?.arrayValue ?? [])
if let summaries = raw["summaries"]?.objectValue {
- Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) {
- ForEach(summaries.keys.sorted(), id: \.self) { id in
- GridRow {
- Text(model.fieldLabel(id, strings: strings))
- Text(verbatim: display(summaries[id])).monospacedDigit().gridColumnAlignment(.trailing)
+ HorizontalOverflowScroll {
+ Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) {
+ ForEach(summaries.keys.sorted(), id: \.self) { id in
+ GridRow {
+ Text(model.fieldLabel(id, strings: strings))
+ Text(verbatim: display(summaries[id])).monospacedDigit().gridColumnAlignment(.trailing)
+ }
}
}
}.font(.caption).textSelection(.enabled)
diff --git a/apps/native/Sources/VisionNativeUI/Components/HorizontalOverflowScroll.swift b/apps/native/Sources/VisionNativeUI/Components/HorizontalOverflowScroll.swift
new file mode 100644
index 000000000..a5485b293
--- /dev/null
+++ b/apps/native/Sources/VisionNativeUI/Components/HorizontalOverflowScroll.swift
@@ -0,0 +1,46 @@
+import SwiftUI
+
+/// The web `
` wrapper (`relative w-full overflow-auto`): the table fills the width it is
+/// offered and, when its content is wider (a narrow window), keeps its content width and
+/// scrolls sideways instead of squeezing its cells into piled-up lines.
+///
+/// A plain `ScrollView(.horizontal)` proposes no width, so its content would stop filling the
+/// card; this layout proposes the larger of the offered width and the content's own width.
+struct HorizontalOverflowScroll: View {
+ @ViewBuilder var content: () -> Content
+ @State private var availableWidth: CGFloat?
+
+ var body: some View {
+ ScrollView(.horizontal) {
+ HorizontalOverflowLayout(availableWidth: availableWidth) { content() }
+ }
+ .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in
+ if availableWidth != width { availableWidth = width }
+ }
+ }
+}
+
+/// Lays its one child out at `max(offered width, the child's ideal width)`.
+struct HorizontalOverflowLayout: Layout {
+ var availableWidth: CGFloat?
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ guard let child = subviews.first else { return .zero }
+ let width = width(proposal: proposal, child: child)
+ return CGSize(width: width, height: child.sizeThatFits(ProposedViewSize(width: width, height: nil)).height)
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ guard let child = subviews.first else { return }
+ child.place(
+ at: bounds.origin, anchor: .topLeading, proposal: ProposedViewSize(width: bounds.width, height: nil))
+ }
+
+ private func width(proposal: ProposedViewSize, child: LayoutSubview) -> CGFloat {
+ let ideal = child.sizeThatFits(.unspecified).width
+ let offered = proposal.width.flatMap { $0.isFinite ? $0 : nil } ?? availableWidth ?? ideal
+ return max(offered, ideal)
+ }
+}
diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift
index fdd711845..f3cd36047 100644
--- a/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift
+++ b/apps/native/Sources/VisionNativeUI/Components/VisionTable.swift
@@ -7,7 +7,9 @@ import VisionNativeCore
/// against the leading edge. This layout fills the width it is offered, as `w-full` does: each
/// column starts at the widest of its cells and the room left over is shared between the
/// columns in proportion to those widths (CSS automatic table layout). When the content is
-/// wider than the card the columns shrink in the same proportion.
+/// wider than the card the columns keep their content widths and the table scrolls sideways
+/// inside its card, as the web ``'s `overflow-auto` wrapper does: cells never squeeze
+/// into piled-up lines.
///
/// The cells are the layout's direct children in row order, one per column. A child marked
/// with `visionTableSpan()` takes a whole row (a divider, an expanded detail row, an empty
@@ -35,11 +37,25 @@ struct VisionTable: View {
var rowHover = true
@ViewBuilder var content: () -> Content
@State private var hoverY: CGFloat?
+ /// The width the table is offered: the scroll view proposes no width to its content.
+ @State private var availableWidth: CGFloat?
@Environment(\.visionTheme) private var theme
var body: some View {
+ ScrollView(.horizontal) {
+ table
+ }
+ .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in
+ if availableWidth != width { availableWidth = width }
+ }
+ }
+
+ private var table: some View {
VisionTableLayout(
- columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns, hoverY: rowHover ? hoverY : nil
+ columns: columns, columnSpacing: columnSpacing, fixedColumns: fixedColumns, hoverY: rowHover ? hoverY : nil,
+ availableWidth: availableWidth
) {
if rowHover {
Rectangle().fill(theme.foreground.opacity(0.03)).allowsHitTesting(false)
@@ -48,7 +64,6 @@ struct VisionTable: View {
}
content()
}
- .frame(maxWidth: .infinity, alignment: .leading)
.onContinuousHover { phase in
guard rowHover else { return }
let y: CGFloat?
@@ -81,6 +96,8 @@ struct VisionTableLayout: Layout {
let fixedColumns: Set
/// The pointer's y in the table, for the hover band; nil hides it.
var hoverY: CGFloat? = nil
+ /// The width to fill when the proposal has none (inside the horizontal scroll view).
+ var availableWidth: CGFloat? = nil
struct Cache {
var width: CGFloat?
@@ -174,8 +191,8 @@ struct VisionTableLayout: Layout {
}
/// CSS automatic table layout for `available` points: the content widths, plus a share of
- /// the spare room in proportion to them (fixed columns keep theirs), or scaled down in
- /// proportion when the content is wider.
+ /// the spare room in proportion to them (fixed columns keep theirs). Content wider than the
+ /// room keeps its widths: the table overflows and scrolls sideways.
static func columnWidths(ideal: [CGFloat], available: CGFloat?, spacing: CGFloat, fixed: Set) -> [CGFloat] {
guard let available, !ideal.isEmpty else { return ideal }
let room = available - spacing * CGFloat(ideal.count - 1)
@@ -194,11 +211,12 @@ struct VisionTableLayout: Layout {
fixed.contains(index) ? width : width + spare * width / flexible
}
}
- return ideal.map { $0 * room / total }
+ return ideal
}
private func plan(width: CGFloat?, subviews: Subviews, cache: inout Cache) -> Plan {
- if let plan = cache.plan, cache.width == width { return plan }
+ let finite = width.flatMap { $0.isFinite ? $0 : nil } ?? availableWidth
+ if let plan = cache.plan, cache.width == finite { return plan }
let count = max(1, columns.count)
let cells = subviews.indices.filter { !subviews[$0][VisionTableBandKey.self] }
let grouped = Self.rows(spans: cells.map { subviews[$0][VisionTableSpanKey.self] }, columnCount: count)
@@ -209,9 +227,9 @@ struct VisionTableLayout: Layout {
ideal[column] = max(ideal[column], subviews[index].sizeThatFits(.unspecified).width)
}
}
- let finite = width.flatMap { $0.isFinite ? $0 : nil }
let widths = Self.columnWidths(ideal: ideal, available: finite, spacing: columnSpacing, fixed: fixedColumns)
- let tableWidth = finite ?? (widths.reduce(0, +) + columnSpacing * CGFloat(count - 1))
+ let contentWidth = widths.reduce(0, +) + columnSpacing * CGFloat(count - 1)
+ let tableWidth = max(finite ?? 0, contentWidth)
var rows: [Plan.Row] = []
var top: CGFloat = 0
for row in grouped {
@@ -232,7 +250,7 @@ struct VisionTableLayout: Layout {
top += height
}
let plan = Plan(columnWidths: widths, rows: rows, size: CGSize(width: tableWidth, height: top))
- cache.width = width
+ cache.width = finite
cache.plan = plan
return plan
}
diff --git a/apps/native/Sources/VisionNativeUI/Components/VisionTextEditorStyle.swift b/apps/native/Sources/VisionNativeUI/Components/VisionTextEditorStyle.swift
index e0865d69e..62a8a78d3 100644
--- a/apps/native/Sources/VisionNativeUI/Components/VisionTextEditorStyle.swift
+++ b/apps/native/Sources/VisionNativeUI/Components/VisionTextEditorStyle.swift
@@ -18,10 +18,12 @@ struct VisionTextEditorStyle: ViewModifier {
func body(content: Content) -> some View {
VisionFieldChrome(
isFocused: isFocused,
- // `px-3` of the chrome minus the 5 pt `NSTextView` text container inset, so text lines up
- // with a `VisionTextFieldStyle` field.
- horizontalPadding: VisionControlMetrics.fieldHorizontalPadding - Self.textContainerInset,
- verticalPadding: VisionControlMetrics.textAreaVerticalPadding - Self.textContainerInset,
+ // `px-3` of the chrome minus the 5 pt line fragment padding `NSTextView` adds on each
+ // side, so text lines up with a `VisionTextFieldStyle` field. That padding is horizontal
+ // only: the text view adds nothing above the first line, so `py-2` is taken in full.
+ // Taking 5 pt off it too put the caret and typed text 5 pt above the placeholder.
+ horizontalPadding: VisionControlMetrics.fieldHorizontalPadding - Self.lineFragmentPadding,
+ verticalPadding: VisionControlMetrics.textAreaVerticalPadding,
minHeight: minHeight, alignment: .topLeading
) {
content
@@ -31,8 +33,8 @@ struct VisionTextEditorStyle: ViewModifier {
}
}
- /// The horizontal inset `NSTextView` adds around its text.
- private static var textContainerInset: CGFloat { 5 }
+ /// The horizontal inset `NSTextView` adds on each side of its text (`lineFragmentPadding`).
+ private static var lineFragmentPadding: CGFloat { 5 }
}
/// The web `Textarea`'s `placeholder`: shown over an empty editor, in the field's font and inset,
diff --git a/apps/native/Sources/VisionNativeUI/NetWorthView.swift b/apps/native/Sources/VisionNativeUI/NetWorthView.swift
index 593ce20a0..acf0a413f 100644
--- a/apps/native/Sources/VisionNativeUI/NetWorthView.swift
+++ b/apps/native/Sources/VisionNativeUI/NetWorthView.swift
@@ -371,19 +371,22 @@ public struct NetWorthView: View {
}
private var table: some View {
VStack(alignment: .leading, spacing: 8) {
- LocalDataTable(
- title: strings.t("networth.dailyBreakdown"), subtitle: "", columns: columns, rows: model.tableRows,
- totalCount: model.tableSnapshots.count,
- query: Binding(get: { model.tableQuery }, set: { model.tableQuery = $0 }),
- filterValues: model.tableFilterValues, strings: strings, serverTotal: model.tableTotal,
- isFetchingMore: model.isFetchingMore, hasMore: model.hasMore
- ) {
- Text(strings.t(model.tableQuery.isNarrowed ? "table.noFilterResults" : "table.noData"))
- }.rowAppeared { id in
- if model.tableRows.suffix(5).contains(where: { $0.id == id }) { Task { await model.loadMore() } }
+ // `VirtualDataTable` is always a `Card` on the web.
+ AdminCard(padding: 0) {
+ LocalDataTable(
+ title: strings.t("networth.dailyBreakdown"), subtitle: "", columns: columns, rows: model.tableRows,
+ totalCount: model.tableSnapshots.count,
+ query: Binding(get: { model.tableQuery }, set: { model.tableQuery = $0 }),
+ filterValues: model.tableFilterValues, strings: strings, serverTotal: model.tableTotal,
+ isFetchingMore: model.isFetchingMore, hasMore: model.hasMore
+ ) {
+ Text(strings.t(model.tableQuery.isNarrowed ? "table.noFilterResults" : "table.noData"))
+ }.rowAppeared { id in
+ if model.tableRows.suffix(5).contains(where: { $0.id == id }) { Task { await model.loadMore() } }
+ }
+ // `VirtualDataTable maxHeight={520}`: the rows scroll under the fixed header.
+ .bodyMaxHeight(520)
}
- // `VirtualDataTable maxHeight={520}`: the rows scroll under the fixed header.
- .bodyMaxHeight(520)
if let error = model.tableError {
Text(error).foregroundStyle(theme.destructive)
Button(strings.t("common.retry")) {
diff --git a/apps/native/Sources/VisionNativeUI/PortfolioDetailHistoryTable.swift b/apps/native/Sources/VisionNativeUI/PortfolioDetailHistoryTable.swift
index cc6b166a2..1e06134b9 100644
--- a/apps/native/Sources/VisionNativeUI/PortfolioDetailHistoryTable.swift
+++ b/apps/native/Sources/VisionNativeUI/PortfolioDetailHistoryTable.swift
@@ -33,16 +33,19 @@ struct PortfolioDetailHistoryTable: View {
} else {
// The web history is a virtualised `max-h-[400px]` scroller: the table's header and
// search stay put and only the rows scroll, drawn lazily.
- LocalDataTable(
- title: "", subtitle: "", columns: columns, rows: rows, totalCount: model.transactions.count,
- query: $query,
- filterValues: { key in
- LocalTableQuery.uniqueValues(model.transactions, key: key, value: rawValue)
- }, strings: strings
- ) {
- Text(strings.t("table.noFilterResults"))
+ // `VirtualDataTable` is always a `Card` on the web.
+ AdminCard(padding: 0) {
+ LocalDataTable(
+ title: "", subtitle: "", columns: columns, rows: rows, totalCount: model.transactions.count,
+ query: $query,
+ filterValues: { key in
+ LocalTableQuery.uniqueValues(model.transactions, key: key, value: rawValue)
+ }, strings: strings
+ ) {
+ Text(strings.t("table.noFilterResults"))
+ }
+ .bodyMaxHeight(400)
}
- .bodyMaxHeight(400)
}
}
}
diff --git a/apps/native/Sources/VisionNativeUI/RebalanceView.swift b/apps/native/Sources/VisionNativeUI/RebalanceView.swift
index 439c127aa..92723c24c 100644
--- a/apps/native/Sources/VisionNativeUI/RebalanceView.swift
+++ b/apps/native/Sources/VisionNativeUI/RebalanceView.swift
@@ -203,25 +203,27 @@ public struct RebalanceView: View {
}
}
card("rebalance.deploymentPlan") {
- Grid(horizontalSpacing: 24, verticalSpacing: 12) {
- GridRow {
- Text(strings.t("rebalance.sleeve")).frame(maxWidth: .infinity, alignment: .leading)
- Text(strings.t("rebalance.current"))
- Text(strings.t("rebalance.target"))
- Text(strings.t("rebalance.deploy"))
- }.font(.callout.weight(.medium)).foregroundStyle(theme.secondaryLabel)
- Divider().gridCellUnsizedAxes(.horizontal)
- ForEach(result.sleeves, id: \.self) { sleeve in
+ HorizontalOverflowScroll {
+ Grid(horizontalSpacing: 24, verticalSpacing: 12) {
GridRow {
- Text(label(sleeve)).frame(maxWidth: .infinity, alignment: .leading)
- Text(money(result.actualValues[sleeve] ?? 0)).monospacedDigit().gridColumnAlignment(.trailing)
- Text(percent(result.targetWeights[sleeve] ?? 0)).monospacedDigit().gridColumnAlignment(
- .trailing)
- if let deploy = result.deployment[sleeve], deploy > 0 {
- VisionBadge(text: money(deploy, signed: true)).monospacedDigit()
- .gridColumnAlignment(.trailing)
- } else {
- Text("—").foregroundStyle(theme.secondaryLabel).gridColumnAlignment(.trailing)
+ Text(strings.t("rebalance.sleeve")).frame(maxWidth: .infinity, alignment: .leading)
+ Text(strings.t("rebalance.current"))
+ Text(strings.t("rebalance.target"))
+ Text(strings.t("rebalance.deploy"))
+ }.font(.callout.weight(.medium)).foregroundStyle(theme.secondaryLabel)
+ Divider().gridCellUnsizedAxes(.horizontal)
+ ForEach(result.sleeves, id: \.self) { sleeve in
+ GridRow {
+ Text(label(sleeve)).frame(maxWidth: .infinity, alignment: .leading)
+ Text(money(result.actualValues[sleeve] ?? 0)).monospacedDigit().gridColumnAlignment(.trailing)
+ Text(percent(result.targetWeights[sleeve] ?? 0)).monospacedDigit().gridColumnAlignment(
+ .trailing)
+ if let deploy = result.deployment[sleeve], deploy > 0 {
+ VisionBadge(text: money(deploy, signed: true)).monospacedDigit()
+ .gridColumnAlignment(.trailing)
+ } else {
+ Text("—").foregroundStyle(theme.secondaryLabel).gridColumnAlignment(.trailing)
+ }
}
}
}
diff --git a/apps/native/Sources/VisionNativeUI/ShellRootView.swift b/apps/native/Sources/VisionNativeUI/ShellRootView.swift
index 5e9ba056e..e62f74cf1 100644
--- a/apps/native/Sources/VisionNativeUI/ShellRootView.swift
+++ b/apps/native/Sources/VisionNativeUI/ShellRootView.swift
@@ -77,6 +77,13 @@ public struct ShellRootView: View {
// and under the titlebar strip; the other states stack them above.
if !isNativeDetail { shellBanners }
ZStack {
+ // `.liquid-canvas` is one fixed layer under every route, never part of a page: the
+ // canvas sits beside the page here, so a page switch (a new `.id`, new page sizes)
+ // never rebuilds it, resizes its Metal drawable or restarts its drift.
+ if isNativeDetail {
+ FrozenWidthLayout(width: frozenDetailWidth) { DetailGlassBackground() }
+ .mask { Rectangle().padding(.vertical, -400) }
+ }
if case .native(let screen) = model.detail {
FrozenWidthLayout(width: frozenDetailWidth) {
NativePageZoomView(factor: model.pageZoom) {
@@ -766,11 +773,11 @@ struct NativeScreenContainer: View {
var body: some View {
content
.environment(\.visionContainerOwnsFocus, focused)
- // PageTransition keys on the pathname; the canvas behind it stays put.
+ // PageTransition keys on the pathname.
.modifier(PageEntrance())
.id(model.route.path)
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(DetailGlassBackground())
+ // The canvas behind the page is `pageColumn`'s, so it outlives the page.
.focusable()
.focusEffectDisabled()
.focused($focused)
diff --git a/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift b/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift
index 9a902d21a..d792580a8 100644
--- a/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift
+++ b/apps/native/Sources/VisionNativeUI/StatisticsFlowView.swift
@@ -40,25 +40,17 @@ struct StatisticsFlowView: View {
hoveredLink == link.id || hoveredNode == link.link.source || hoveredNode == link.link.target
// Stroked with the target node's colour, 25% opaque, 60% when highlighted.
let target = layout.nodes.first { $0.id == link.link.target }
- // The band as its stroked outline, so the view's own shape is the hit area: the
- // pointer is on a link only over its band, as an SVG `` stroke is.
- curve(link, margin: margin)
+ // The band as its stroked outline: the same path the hover test below uses.
+ Self.curve(link, margin: margin)
.strokedPath(StrokeStyle(lineWidth: max(1, CGFloat(link.width))))
.fill(target.map { color($0.node) } ?? theme.mutedForeground)
.opacity(isHovered ? 0.6 : 0.25)
.animation(fade, value: isHovered)
- .onHover { inside in
- // `onMouseEnter` / `onMouseLeave`; a late leave never clears a newer link.
- if inside {
- if hoveredLink != link.id { hoveredLink = link.id }
- } else if hoveredLink == link.id {
- hoveredLink = nil
- }
- }
+ .allowsHitTesting(false)
}
.accessibilityHidden(true)
ForEach(layout.nodes) { node in
- let bounds = rect(node, margin: margin)
+ let bounds = Self.rect(node, margin: margin)
let isHovered = hoveredNode == node.id
// Labels 6 pt off the node edge at its vertical middle, like the web chart: nodes in
// the right half carry theirs on the left (end-anchored), all others on the right.
@@ -68,14 +60,7 @@ struct StatisticsFlowView: View {
.opacity(isHovered ? 1 : 0.85)
.animation(fade, value: isHovered)
.frame(width: bounds.width, height: bounds.height)
- .contentShape(Rectangle())
- .onHover { inside in
- if inside {
- if hoveredNode != node.id { hoveredNode = node.id }
- } else if hoveredNode == node.id {
- hoveredNode = nil
- }
- }
+ .allowsHitTesting(false)
.position(x: bounds.midX, y: bounds.midY)
.accessibilityLabel(label(node.node))
.accessibilityValue(
@@ -105,23 +90,51 @@ struct StatisticsFlowView: View {
}
}
.frame(width: size.width, height: size.height, alignment: .topLeading)
- // Each node bar and link band tracks its own hover (SVG `onMouseEnter`/`onMouseLeave`),
- // nodes drawn over links. One tracker for the whole chart, testing nodes first by their
- // rectangles, lit a node's whole section over the band beside it.
+ .contentShape(Rectangle())
+ // SwiftUI's `onHover` tracks a view's whole frame, not its shape, so per-band trackers
+ // all covered the chart and the topmost band won everywhere. One tracker hit-tests the
+ // SVG way instead: the topmost painted shape under the pointer, node bars (drawn over
+ // the bands) by their exact rectangles, then bands by their stroked outlines, last
+ // drawn first. Empty space hovers nothing.
+ .onContinuousHover { phase in
+ switch phase {
+ case .active(let point):
+ let hit = Self.hit(at: point, layout: layout, margin: margin)
+ if hoveredNode != hit.node { hoveredNode = hit.node }
+ if hoveredLink != hit.link { hoveredLink = hit.link }
+ case .ended:
+ if hoveredNode != nil { hoveredNode = nil }
+ if hoveredLink != nil { hoveredLink = nil }
+ }
+ }
}.frame(minHeight: 380)
.onChange(of: hoveredNode) { _, _ in onHoverChange(hoveredNode, hoveredLink) }
.onChange(of: hoveredLink) { _, _ in onHoverChange(hoveredNode, hoveredLink) }
}
+ /// The node or band painted topmost at `point`, as SVG `onMouseEnter` targets resolve.
+ static func hit(
+ at point: CGPoint, layout: StatisticsFlowLayout, margin: CGFloat
+ ) -> (node: String?, link: String?) {
+ for node in layout.nodes.reversed() where rect(node, margin: margin).contains(point) {
+ return (node.id, nil)
+ }
+ for link in layout.links.reversed() {
+ let band = curve(link, margin: margin).strokedPath(StrokeStyle(lineWidth: max(1, CGFloat(link.width))))
+ if band.contains(point) { return (nil, link.id) }
+ }
+ return (nil, nil)
+ }
+
/// The node's rectangle in view coordinates; at least 1 pt tall, as on the web.
- private func rect(_ node: StatisticsFlowNodeLayout, margin: CGFloat) -> CGRect {
+ private static func rect(_ node: StatisticsFlowNodeLayout, margin: CGFloat) -> CGRect {
CGRect(
x: margin + CGFloat(node.x0), y: margin + CGFloat(node.y0),
width: CGFloat(node.x1 - node.x0), height: max(1, CGFloat(node.y1 - node.y0)))
}
/// The link's centre line: the web `sankeyLinkPath` curve, M sx,y0 C mx,y0 mx,y1 tx,y1.
- private func curve(_ link: StatisticsFlowLinkLayout, margin: CGFloat) -> Path {
+ private static func curve(_ link: StatisticsFlowLinkLayout, margin: CGFloat) -> Path {
let sx = margin + CGFloat(link.sourceX)
let tx = margin + CGFloat(link.targetX)
let mx = (sx + tx) / 2
diff --git a/apps/native/Sources/VisionNativeUI/StatisticsView.swift b/apps/native/Sources/VisionNativeUI/StatisticsView.swift
index c560de060..f93818149 100644
--- a/apps/native/Sources/VisionNativeUI/StatisticsView.swift
+++ b/apps/native/Sources/VisionNativeUI/StatisticsView.swift
@@ -652,29 +652,32 @@ public struct StatisticsView: View {
let visible = Array(model.recipientRows.prefix(model.recipientVisibleCount))
let ranks = Dictionary(
visible.enumerated().map { index, row in (row.id, index + 1) }, uniquingKeysWith: { first, _ in first })
- return LocalDataTable(
- title: strings.t("insights.detailsTitle"), subtitle: strings.t("insights.detailsSubtitle"),
- columns: [recipientRankColumn(ranks)] + recipientColumns,
- rows: visible,
- totalCount: data.topMerchants.count,
- query: Binding(get: { model.recipientQuery }, set: { model.recipientQuery = $0 }),
- filterValues: model.recipientFilterValues, strings: strings,
- serverTotal: model.recipientRows.count,
- hasMore: model.recipientVisibleCount < model.recipientRows.count
- ) {
- Text(
- strings.t(
- model.recipientQuery.isNarrowed ? "table.noFilterResults" : "insights.detailsEmpty"))
- }
- .rowAppeared { id in
- if model.recipientRows.prefix(model.recipientVisibleCount).suffix(5).contains(where: {
- $0.id == id
- }
+ // `VirtualDataTable` is always a `Card` on the web.
+ return AdminCard(padding: 0) {
+ LocalDataTable(
+ title: strings.t("insights.detailsTitle"), subtitle: strings.t("insights.detailsSubtitle"),
+ columns: [recipientRankColumn(ranks)] + recipientColumns,
+ rows: visible,
+ totalCount: data.topMerchants.count,
+ query: Binding(get: { model.recipientQuery }, set: { model.recipientQuery = $0 }),
+ filterValues: model.recipientFilterValues, strings: strings,
+ serverTotal: model.recipientRows.count,
+ hasMore: model.recipientVisibleCount < model.recipientRows.count
) {
- model.loadMoreRecipients(pageSize: settings.defaultPageSize)
+ Text(
+ strings.t(
+ model.recipientQuery.isNarrowed ? "table.noFilterResults" : "insights.detailsEmpty"))
+ }
+ .rowAppeared { id in
+ if model.recipientRows.prefix(model.recipientVisibleCount).suffix(5).contains(where: {
+ $0.id == id
+ }
+ ) {
+ model.loadMoreRecipients(pageSize: settings.defaultPageSize)
+ }
}
+ .bodyMaxHeight(700)
}
- .bodyMaxHeight(700)
}
/// The "#" column: the row's position in the list, not sortable or filterable.
private func recipientRankColumn(_ ranks: [RecipientID: Int]) -> LocalDataColumn {
diff --git a/apps/native/Sources/VisionNativeUI/TransactionsTable.swift b/apps/native/Sources/VisionNativeUI/TransactionsTable.swift
index dee9cee6d..e953bf411 100644
--- a/apps/native/Sources/VisionNativeUI/TransactionsTable.swift
+++ b/apps/native/Sources/VisionNativeUI/TransactionsTable.swift
@@ -56,9 +56,19 @@ struct TransactionsTableCard: View {
searchBar
.zIndex(1)
if !model.columnFilters.isEmpty { chips }
- headerRow
- Divider()
- rowsList
+ // The web table keeps its columns' widths and scrolls sideways inside the card when
+ // the window is narrower; the rows scroll vertically under the fixed header.
+ ScrollView(.horizontal) {
+ VStack(alignment: .leading, spacing: 0) {
+ headerRow
+ Divider()
+ rowsList
+ }
+ .containerRelativeFrame(.horizontal, alignment: .leading) { length, _ in
+ max(length, minimumTableWidth)
+ }
+ }
+ .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
if model.isFetchingMore {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
@@ -224,6 +234,14 @@ struct TransactionsTableCard: View {
).mapValues { CGFloat($0) }
}
+ /// The width under which the columns stop shrinking and the table scrolls sideways: the
+ /// chrome plus every column at its fitted width.
+ private var minimumTableWidth: CGFloat {
+ let fitted = fittedWidths
+ return Self.selectWidth + Self.editWidth + 32 + (isCompact ? Self.summaryWidth : 0)
+ + dataColumns.reduce(0) { $0 + width($1, fitted: fitted) }
+ }
+
private func width(_ column: TransactionColumn, fitted: [String: CGFloat]) -> CGFloat {
fitted[column.rawValue] ?? CGFloat(column.width)
}