Jul 29 - Update codes to support iPhone

This commit is contained in:
Nguyen Ngo
2026-07-29 15:51:08 -04:00
parent 9926739cb6
commit b7990cc9ba
5 changed files with 355 additions and 178 deletions
@@ -870,6 +870,25 @@ struct GridFormView: View {
static let cellAspect: CGFloat = 52/72 // cellH / cellW matches editor CELL_H/CELL_W
static let cardPadding: CGFloat = 16 // card inset on all sides
// Below this container width the 12-column grid stops being usable: at
// 375 pt (iPhone SE/6/7/8) a column is only ~21 pt wide and a row ~15 pt
// tall, so a normal 6x2 field renders ~167x35 pt the label alone eats
// most of it. Cells are absolutely positioned and deliberately unclipped
// (see body), so the overflow draws on top of the row beneath and the
// form becomes an unreadable pile. Under this width we reflow to one
// field per line instead. 600 pt keeps a column at >=40 pt.
static let minGridWidth: CGFloat = 600
// Heights for widgets that have no intrinsic size of their own. In the
// absolute grid these are driven by rowSpan; in the stacked layout there
// is no rowSpan to read, so they would otherwise collapse to nothing.
static let stackedMinH: [String: CGFloat] = [
"textarea": 96,
"signature": 120,
"table": 120,
"image": 88,
]
// Minimum cell height (points) per field type ensures 44pt touch targets
// on iPad even when the template author assigned a very short rowSpan.
static let minCellH: [String: CGFloat] = [
@@ -894,54 +913,115 @@ struct GridFormView: View {
@State private var containerWidth: CGFloat = 0
var body: some View {
ZStack(alignment: .topLeading) {
// Card background
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground))
Group {
if containerWidth > 0 && isCompact {
// Compact: content drives the height
// The card is a .background modifier rather than a ZStack
// sibling so it takes its size FROM the stack. As a ZStack
// sibling the flexible RoundedRectangle competes with the
// VStack for the container's size and the card ends up
// shorter than its own content, cutting off the last fields.
stackedLayout
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground))
)
} else {
// Regular: absolute 12-column canvas
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground))
// Width probe zero-size overlay, reports container width
// Using a background Color.clear with a GeometryReader that sends
// its width via PreferenceKey is the idiomatic SwiftUI pattern that
// works correctly inside ScrollView on all iOS versions.
Color.clear
.frame(maxWidth: .infinity)
.frame(height: 0)
.background(
GeometryReader { geo in
Color.clear.preference(
key: WidthPreferenceKey.self,
value: geo.size.width
)
}
)
// Field overlays only rendered after width is measured.
// containerWidth == 0 means the PreferenceKey has not fired
// yet (first layout pass). Skipping the overlay pass on the
// zero frame prevents fields from being positioned using a
// stale width and overflowing the modal on narrow sheet
// presentations (iPad 10th gen).
if containerWidth > 0 {
let cellW = computedCellW
let cellH = cellW * Self.cellAspect
// Field overlays only rendered after width is measured
// containerWidth == 0 means the PreferenceKey has not fired yet
// (first layout pass). Skipping the overlay pass on the zero frame
// prevents fields from being positioned using a stale width and
// overflowing the modal on narrow sheet presentations (iPad 10th gen).
if containerWidth > 0 {
let cellW = computedCellW
let cellH = cellW * Self.cellAspect
ForEach(schema.indices, id: \.self) { idx in
let field = schema[idx]
let ftype = field["type"] as? String ?? "text"
if !["button_submit", "button_print", "button_email"].contains(ftype) {
gridCell(field: field, cellW: cellW, cellH: cellH)
ForEach(schema.indices, id: \.self) { idx in
let field = schema[idx]
let ftype = field["type"] as? String ?? "text"
if !["button_submit", "button_print", "button_email"].contains(ftype) {
gridCell(field: field, cellW: cellW, cellH: cellH)
}
}
}
}
// Height is derived from the same arithmetic as the cell
// offsets the ScrollView measures this frame and can never
// be wrong.
.frame(height: containerWidth > 0
? canvasHeight() + 2 * Self.cardPadding
: 0)
}
}
// Width probe
// Attached as a background so it reports the resolved container width
// without taking part in sizing the content itself.
.background(
GeometryReader { geo in
Color.clear.preference(
key: WidthPreferenceKey.self,
value: geo.size.width
)
}
)
.onPreferenceChange(WidthPreferenceKey.self) { width in
if width > 0 { containerWidth = width }
}
// Height is always derived from the same arithmetic as cell offsets
// the ScrollView measures this frame and can never be wrong.
// When containerWidth is 0, canvasHeight() still returns the correct
// value (it uses computedCellW which returns 0 when containerWidth is 0),
// so the card reserves space and avoids a layout jump.
.frame(height: containerWidth > 0 ? canvasHeight() + 2 * Self.cardPadding : 0)
}
// Compact (narrow) layout
// One field per line, full width, natural height. Fields are ordered by
// (row, col) because the form editor stores them in drag/creation order,
// not visual order the same sort the PDF and read-only renderers use
// (rule 62). Nothing is absolutely positioned here, so nothing can
// overlap regardless of how narrow the screen gets.
private var isCompact: Bool { containerWidth < Self.minGridWidth }
private var orderedFields: [[String: Any]] {
schema
.filter { f in
let t = f["type"] as? String ?? "text"
return !["button_submit", "button_print", "button_email"].contains(t)
}
.sorted {
let r0 = $0["row"] as? Int ?? 0, r1 = $1["row"] as? Int ?? 0
if r0 != r1 { return r0 < r1 }
return ($0["col"] as? Int ?? 0) < ($1["col"] as? Int ?? 0)
}
}
private var stackedLayout: some View {
let fields = orderedFields
return VStack(alignment: .leading, spacing: 14) {
ForEach(fields.indices, id: \.self) { idx in
let field = fields[idx]
let ftype = field["type"] as? String ?? "text"
let fid = fieldId(field)
GridCellContentView(
field: field,
value: Binding(
get: { formValues[fid] ?? "" },
set: { formValues[fid] = $0; onFieldChanged?() }
),
onPhotoSelected: { path in onPhotoSelected?(path, field) }
)
.frame(
maxWidth: .infinity,
minHeight: Self.stackedMinH[ftype] ?? 0,
alignment: .topLeading
)
}
}
.padding(Self.cardPadding)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
// Derived cell width from current containerWidth