Update layout

This commit is contained in:
isaac 2026-05-30 23:27:26 +02:00
parent 3525bcacb2
commit f6dc52c533
5 changed files with 118 additions and 159 deletions

View file

@ -103,8 +103,8 @@ A V2 `.table` block's item frame is **full-width / flush** with the bubble inter
### Non-obvious invariants
- **`InstantPageV2TableItem.contentInset` (= page `horizontalInset`) is the linchpin.** `layoutTable` (`InstantPageV2Layout.swift`) sizes columns against `contentBoundingWidth = boundingWidth horizontalInset·2` (so a fitting table aligns with body text on both sides) and stores `contentInset` on the item; the item `frame.width` is the flush `boundingWidth`, and `contentSize.width` stays the **bare grid width** (`totalWidth`, no inset).
- **The renderer (`InstantPageV2TableView`) realizes the inset as a view shift, not baked coordinates.** In `init` AND `update` it shifts the grid `contentView` to `x: contentInset`, sets `scrollView.contentSize.width = contentSize.width + contentInset` (**left margin only**), and `scrollView.clipsToBounds = true`. Cells, inner border lines, and the title stay x=0-relative inside `contentView`, so the single shift carries them all; the rounded outer border is `contentView.layer`'s own border (see below), which wraps the shifted layer automatically.
- **Scrollable tables clip to the full width with no inset on the clip.** The inset lives inside the scroll content as a left margin only: a fitting table (`grid + inset ≤ boundingWidth`) doesn't scroll and shows both-side inset; an overflowing table rests with its left border at the inset and scrolls its right border flush to the full-width edge (no trailing gap).
- **The renderer (`InstantPageV2TableView`) realizes the inset as a view shift, not baked coordinates.** In `init` AND `update` it shifts the grid `contentView` to `x: contentInset`, sets `scrollView.contentSize.width = contentSize.width + contentInset * 2.0` (**margin on both sides**, mirroring V1's `InstantPageScrollableNode`), and `scrollView.clipsToBounds = true`. Cells, inner border lines, and the title stay x=0-relative inside `contentView`, so the single shift carries them all; the rounded outer border is `contentView.layer`'s own border (see below), which wraps the shifted layer automatically.
- **Scrollable tables clip to the full width with no inset on the clip.** The inset lives inside the scroll content as a symmetric margin on both sides (`contentInset * 2.0`): a fitting table (`grid + 2·inset ≤ boundingWidth`) doesn't scroll and shows both-side inset; an overflowing table rests with its left border at the inset and scrolls until its right border reaches a matching trailing inset (it does **not** jam flush against the screen edge — matches V1). The scroll-indicator threshold and `contentSize.width` use the same `+ contentInset * 2.0`, so "does it scroll" is exactly `grid > boundingWidth 2·inset`.
- **Manual cell-coordinate helpers MUST add `contentInset`.** Because the shift is a real `contentView` frame change, UIKit `hitTest` and `self.convert(_:to:)` paths (`propagateVisibilityRect`, the row-reveal mask) handle it automatically — but the *manual* coordinate helpers `findTextItem` / `collectSelectableTextItems` (the live tap / URL / text-selection path) compute cell/title positions arithmetically and must add `table.contentInset` to the x-offset, or in-cell hit-testing is off by the inset. (These helpers still do **not** account for the table's live horizontal `scrollView.contentOffset` — a pre-existing limitation, so in-cell hit-testing is only correct at scroll offset 0.) The dead-but-symmetric `lastTextLineFrame(in:)` table branch has the same omission but has no callers.
- **The 10pt rounded outer border is `contentView.layer`'s own border, NOT sublayers.** `v2TableCornerRadius = 10.0` (`InstantPageV2Layout.swift`). The renderer sets `contentView.layer.cornerRadius`/`borderColor`/`borderWidth = bordered ? v2TableBorderWidth : 0.0` in BOTH `init` and `update` (the four straight outer-edge rect layers were removed; `lineLayers` now holds only inner grid lines). **Border-only — deliberately no `masksToBounds`:** `cornerRadius` rounds the layer's border without clipping contents (filled corner cells round their own fills separately — see next bullet), and there is **zero interaction with the streaming reveal mask** (`contentView.layer.mask`, set only during AI streaming) — the border reveals row-by-row with the rows and is part of the masked layer. The rounded card belongs to the grid (scrolls with it). For a non-empty-title table (never produced by markdown/AI), the border wraps title+grid since `contentView` includes the title region — an accepted, approved nuance.
- **Filled corner cells round their own fills to match the border.** A header/striped cell's background is a stripe `CALayer`; `tableStripeCornerMask(cellFrame:gridWidth:gridHeight:effectiveBorderWidth:)` detects which grid corners the cell's (grid-local) frame touches — `firstCol/firstRow` via `frame.min{X,Y} <= effectiveBorderWidth/2 + 0.5`, `lastCol/lastRow` via `frame.max{X,Y} >= grid{Width,Height} - …` (gridWidth = `item.contentSize.width`, gridHeight = `item.contentSize.height - gridOffsetY`) — and rounds only those corners: `stripe.cornerRadius = max(0, v2TableCornerRadius - effectiveBorderWidth)` (the `-borderWidth` leaves an even border ring; borderless → full radius) + `stripe.maskedCorners`, in BOTH `init` and `update`. A `CALayer`'s `backgroundColor` honors `cornerRadius`+`maskedCorners` with no `masksToBounds`. A full-width (colspan) header rounds both top corners; a one-row filled table rounds all four; bottom corners round only when the last row is filled. The empty-mask branch resets `cornerRadius = 0` **and** `maskedCorners = []` so reused stripes (persist across streaming chunks) don't keep stale rounding. Detection is grid-local, so it's independent of the `contentInset` shift / horizontal scroll.

View file

@ -113,7 +113,7 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
}
} else if let lower {
switch lower {
case .cover, .channelBanner, .details, .anchor, .table:
case .cover, .channelBanner, .details, .anchor:
return 0.0
default:
if fitToWidth {

View file

@ -285,10 +285,10 @@ public final class InstantPageV2View: UIView {
let enableSpoilerAnimations = self.renderContext.map { $0.context.sharedContext.energyUsageSettings.fullTranslucency } ?? true
for view in self.itemViews {
if let textView = view as? InstantPageV2TextView {
// Both fresh (makeItemView) and reused text views now build their dust through the
// single initupdateupdateSpoiler path, so we only push the external animation
// setting here; its didSet rebuilds the dust if the value actually changed.
textView.enableSpoilerAnimations = enableSpoilerAnimations
// makeItemView builds fresh text views via init only (no update(item:theme:)), so
// build their dust here; updateSpoiler is idempotent (no-op when there are no spoilers).
textView.updateSpoiler(animated: false)
}
}
// Force the current reveal state (true OR false) onto every text view every layout, so a
@ -728,19 +728,19 @@ public final class InstantPageV2View: UIView {
private func makeItemView(for item: InstantPageV2LaidOutItem, theme: InstantPageTheme) -> InstantPageItemView? {
switch item {
case let .text(text):
return InstantPageV2TextView(item: text)
return InstantPageV2TextView(item: text, theme: theme)
case let .divider(divider):
return InstantPageV2DividerView(item: divider)
return InstantPageV2DividerView(item: divider, theme: theme)
case let .anchor(anchor):
return InstantPageV2AnchorView(item: anchor)
return InstantPageV2AnchorView(item: anchor, theme: theme)
case let .listMarker(marker):
return InstantPageV2ListMarkerView(item: marker)
return InstantPageV2ListMarkerView(item: marker, theme: theme)
case let .codeBlock(block):
return InstantPageV2CodeBlockView(item: block)
return InstantPageV2CodeBlockView(item: block, theme: theme)
case let .blockQuoteBar(bar):
return InstantPageV2BlockQuoteBarView(item: bar)
return InstantPageV2BlockQuoteBarView(item: bar, theme: theme)
case let .shape(shape):
return InstantPageV2ShapeView(item: shape)
return InstantPageV2ShapeView(item: shape, theme: theme)
case let .mediaPlaceholder(media):
return InstantPageV2MediaPlaceholderView(item: media, theme: theme)
case let .details(details):
@ -776,9 +776,9 @@ public final class InstantPageV2View: UIView {
return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme)
}
case let .formula(formula):
return InstantPageV2FormulaView(item: formula)
return InstantPageV2FormulaView(item: formula, theme: theme)
case let .thinking(thinking):
return InstantPageV2ThinkingView(item: thinking)
return InstantPageV2ThinkingView(item: thinking, theme: theme)
}
}
@ -880,33 +880,25 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
private var revealLineMaskLayers: [SimpleLayer] = []
private var animatingSnippetLayers: [SnippetLayer] = []
init(item: InstantPageV2TextItem) {
init(item: InstantPageV2TextItem, theme: InstantPageTheme) {
self.item = item
self.renderContainer = UIView()
self.renderView = TextRenderView(item: item)
super.init(frame: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset))
// Structural wiring only (one-time); all frames/content live in update(item:theme:).
self.backgroundColor = .clear
self.isOpaque = false
self.renderContainer.frame = self.bounds
self.renderContainer.backgroundColor = .clear
self.renderContainer.isOpaque = false
self.addSubview(self.renderContainer)
self.renderView.frame = self.bounds
self.renderContainer.addSubview(self.renderView)
self.imageContainerView.frame = self.bounds
self.imageContainerView.isUserInteractionEnabled = false
self.addSubview(self.imageContainerView)
self.emojiContainerView.frame = self.bounds
self.emojiContainerView.isUserInteractionEnabled = false
self.addSubview(self.emojiContainerView)
self.spoilerContainerView.frame = self.bounds
self.spoilerContainerView.isUserInteractionEnabled = false
self.addSubview(self.spoilerContainerView)
self.update(item: item, theme: theme)
}
@available(*, unavailable)
@ -917,11 +909,18 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
func update(item: InstantPageV2TextItem, theme: InstantPageTheme) {
let _ = theme
self.item = item
// Lay every container out from the item's own (clipping-inset-expanded) frame rather than
// self.bounds, so the single path is correct regardless of when the parent assigns our
// frame and so a reused text view that changed size (e.g. AI streaming) re-frames its
// renderContainer/renderView too, which the old update path skipped.
let containerBounds = CGRect(origin: .zero, size: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset).size)
self.renderContainer.frame = containerBounds
self.renderView.frame = containerBounds
self.renderView.item = item
self.renderView.setNeedsDisplay()
self.imageContainerView.frame = self.bounds
self.emojiContainerView.frame = self.bounds
self.spoilerContainerView.frame = self.bounds
self.imageContainerView.frame = containerBounds
self.emojiContainerView.frame = containerBounds
self.spoilerContainerView.frame = containerBounds
self.renderView.displayContentsUnderSpoilers = self.displayContentsUnderSpoilers
self.updateSpoiler(animated: false)
}
@ -1452,10 +1451,10 @@ final class InstantPageV2DividerView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2DividerItem
var itemFrame: CGRect { return self.item.frame }
init(item: InstantPageV2DividerItem) {
init(item: InstantPageV2DividerItem, theme: InstantPageTheme) {
self.item = item
super.init(frame: item.frame)
self.backgroundColor = item.color
self.update(item: item, theme: theme)
}
@available(*, unavailable)
@ -1474,10 +1473,11 @@ final class InstantPageV2AnchorView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2AnchorItem
var itemFrame: CGRect { return self.item.frame }
init(item: InstantPageV2AnchorItem) {
init(item: InstantPageV2AnchorItem, theme: InstantPageTheme) {
self.item = item
super.init(frame: item.frame)
self.isHidden = true
self.isHidden = true // structural: zero-height, never renders
self.update(item: item, theme: theme)
}
@available(*, unavailable)
@ -1495,12 +1495,12 @@ final class InstantPageV2ListMarkerView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2ListMarkerItem
var itemFrame: CGRect { return self.item.frame }
init(item: InstantPageV2ListMarkerItem) {
init(item: InstantPageV2ListMarkerItem, theme: InstantPageTheme) {
self.item = item
super.init(frame: item.frame)
self.backgroundColor = .clear
self.isOpaque = false
self.rebuildContents()
self.backgroundColor = .clear // structural
self.isOpaque = false // structural
self.update(item: item, theme: theme)
}
@available(*, unavailable)
@ -1569,11 +1569,10 @@ final class InstantPageV2BlockQuoteBarView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2BarItem
var itemFrame: CGRect { return self.item.frame }
init(item: InstantPageV2BarItem) {
init(item: InstantPageV2BarItem, theme: InstantPageTheme) {
self.item = item
super.init(frame: item.frame)
self.backgroundColor = item.color
self.layer.cornerRadius = item.cornerRadius
self.update(item: item, theme: theme)
}
@available(*, unavailable)
@ -1593,10 +1592,10 @@ final class InstantPageV2ShapeView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2ShapeItem
var itemFrame: CGRect { return self.item.frame }
init(item: InstantPageV2ShapeItem) {
init(item: InstantPageV2ShapeItem, theme: InstantPageTheme) {
self.item = item
super.init(frame: item.frame)
self.applyKind()
self.update(item: item, theme: theme)
}
@available(*, unavailable)
@ -1629,9 +1628,7 @@ final class InstantPageV2MediaPlaceholderView: UIView, InstantPageItemView {
init(item: InstantPageV2MediaPlaceholderItem, theme: InstantPageTheme) {
self.item = item
super.init(frame: item.frame)
self.backgroundColor = theme.imageTintColor?.withAlphaComponent(0.2) ?? UIColor(white: 0.85, alpha: 1.0)
self.layer.cornerRadius = item.cornerRadius
self.clipsToBounds = item.cornerRadius > 0.0
self.update(item: item, theme: theme)
}
@available(*, unavailable)
@ -1679,45 +1676,36 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
frame: item.titleTextItem.frame,
textItem: item.titleTextItem
)
self.titleTextView = InstantPageV2TextView(item: titleV2Item)
self.titleTextView = InstantPageV2TextView(item: titleV2Item, theme: theme)
self.titleTextView.isUserInteractionEnabled = false
self.chevronView = UIImageView()
self.chevronView.image = UIImage(bundleImageName: "Item List/ExpandingItemVerticalRegularArrow")?.withRenderingMode(.alwaysTemplate)
self.chevronView.tintColor = theme.textCategories.paragraph.color
self.chevronView.contentMode = .scaleAspectFit
// Decorative: let taps fall through to titleHitView (which carries the toggle gesture).
self.chevronView.isUserInteractionEnabled = false
self.separator = UIView()
self.separator.backgroundColor = item.separatorColor
self.separator.isUserInteractionEnabled = false
self.titleHitView = UIView(frame: item.titleFrame)
self.titleHitView = UIView()
self.titleHitView.backgroundColor = .clear
super.init(frame: item.frame)
self.backgroundColor = .clear
self.clipsToBounds = true
self.backgroundColor = .clear // structural
self.clipsToBounds = true // structural the parent's frame-height animation clips the body
self.addSubview(self.titleTextView)
self.addSubview(self.chevronView)
self.addSubview(self.separator)
if item.isExpanded, let innerLayout = item.innerLayout {
let body = InstantPageV2View(renderContext: renderContext)
body.update(layout: innerLayout, theme: theme, animation: .None)
body.frame = CGRect(
origin: CGPoint(x: 0.0, y: item.titleFrame.maxY),
size: innerLayout.contentSize
)
self.addSubview(body)
self.bodyView = body
}
let tap = UITapGestureRecognizer(target: self, action: #selector(self.titleTapped))
self.insertSubview(self.titleHitView, at: 0)
self.titleHitView.addGestureRecognizer(tap)
// All content (title, chevron tint/position, separator, titleHit frame, body) flows through
// update its expanded branch lazily creates the body, so init no longer builds it itself.
self.update(item: item, theme: theme, renderContext: renderContext, animation: .None)
}
@available(*, unavailable)
@ -1824,25 +1812,23 @@ final class InstantPageV2CodeBlockView: UIView, InstantPageItemView {
private let backgroundLayer: CALayer
let textView: InstantPageV2TextView
init(item: InstantPageV2CodeBlockItem) {
init(item: InstantPageV2CodeBlockItem, theme: InstantPageTheme) {
self.item = item
self.backgroundLayer = CALayer()
self.backgroundLayer.backgroundColor = item.backgroundColor.cgColor
self.backgroundLayer.cornerRadius = item.cornerRadius
self.backgroundLayer.frame = CGRect(origin: .zero, size: item.frame.size)
// item.textItem.frame is already in code-block content-area coords (x=17, y=backgroundInset).
let innerV2TextItem = InstantPageV2TextItem(
frame: item.textItem.frame,
textItem: item.textItem
)
self.textView = InstantPageV2TextView(item: innerV2TextItem)
self.textView = InstantPageV2TextView(item: innerV2TextItem, theme: theme)
super.init(frame: item.frame)
self.backgroundColor = .clear
self.layer.addSublayer(self.backgroundLayer)
self.addSubview(self.textView)
self.backgroundColor = .clear // structural
self.layer.addSublayer(self.backgroundLayer) // structural
self.addSubview(self.textView) // structural
self.update(item: item, theme: theme)
}
@available(*, unavailable)
@ -1875,17 +1861,17 @@ final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
private let shimmerView: ShimmeringMaskView
private let textView: InstantPageV2TextView
init(item: InstantPageV2ThinkingItem) {
init(item: InstantPageV2ThinkingItem, theme: InstantPageTheme) {
self.item = item
self.shimmerView = ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)
let innerV2TextItem = InstantPageV2TextItem(frame: item.textItem.frame, textItem: item.textItem)
self.textView = InstantPageV2TextView(item: innerV2TextItem)
self.textView = InstantPageV2TextView(item: innerV2TextItem, theme: theme)
super.init(frame: item.frame)
self.backgroundColor = .clear
self.addSubview(self.shimmerView)
self.shimmerView.contentView.addSubview(self.textView)
self.layoutContents()
self.backgroundColor = .clear // structural
self.addSubview(self.shimmerView) // structural
self.shimmerView.contentView.addSubview(self.textView) // structural
self.update(item: item, theme: theme)
}
@available(*, unavailable)
@ -1957,84 +1943,51 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
super.init(frame: item.frame)
self.backgroundColor = .clear
self.scrollView.frame = self.bounds
// Structural, one-time scroll-view configuration. Frames / contentSize / indicator
// visibility all depend on the item and are (re)applied by update(item:theme:).
// Scrollable tables clip to the full width with no inset on the clip; the inset lives inside
// the scroll content width as a left margin only (see contentSize below).
// the scroll content width as a margin on BOTH sides (`contentInset * 2.0`, mirroring V1's
// `InstantPageScrollableNode`), so a scrolled-to-the-end table keeps a symmetric trailing
// inset instead of jamming its right border flush against the screen edge.
self.scrollView.clipsToBounds = true
self.scrollView.contentSize = CGSize(width: item.contentSize.width + item.contentInset, height: item.contentSize.height)
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.alwaysBounceVertical = false
self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width + item.contentInset > item.frame.width
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.disablesInteractiveTransitionGestureRecognizer = true
self.addSubview(self.scrollView)
// Grid container shifted right by the inset so the borders start at the body-text inset.
// Its size stays the bare grid (item.contentSize); grid coords inside remain x=0-relative.
self.contentView.frame = CGRect(x: item.contentInset, y: 0.0, width: item.contentSize.width, height: item.contentSize.height)
self.scrollView.addSubview(self.contentView)
// Title sub-layout (above the grid, inside the scroll view's content).
if let titleLayout = item.titleSubLayout, let titleFrame = item.titleFrame {
// Build the (content-less) child structure sized to the construction-time item; update fills
// every frame / colour / sub-layout below. Insertion order matches the original interleaved
// build so the layer/subview z-order is unchanged (stripes at the bottom, then the title and
// cell sub-views, then the inner grid lines). Cell-count changes on later reuse are not
// reconciled here (pre-existing limitation) update's index-guarded loops refresh in place.
if item.titleSubLayout != nil {
let v = InstantPageV2View(renderContext: renderContext)
v.update(layout: titleLayout, theme: theme, animation: .None)
v.frame = CGRect(x: v2TableCellInsets.left, y: titleFrame.minY + v2TableCellInsets.top,
width: titleLayout.contentSize.width, height: titleLayout.contentSize.height)
self.contentView.addSubview(v)
self.titleSubView = v
}
// Grid origin: shifted down by title height when present.
let gridOffsetY = item.titleFrame?.height ?? 0.0
let effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0
let gridHeight = item.contentSize.height - gridOffsetY
// Cell backgrounds and sub-layouts.
for cell in item.cells {
if let bg = cell.backgroundColor {
if cell.backgroundColor != nil {
let stripe = CALayer()
stripe.backgroundColor = bg.cgColor
stripe.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
// Round the stripe at any grid corner it touches, so a filled corner cell
// follows the rounded outer border instead of poking past it.
let cornerMask = tableStripeCornerMask(cellFrame: cell.frame, gridWidth: item.contentSize.width, gridHeight: gridHeight, effectiveBorderWidth: effectiveBorderWidth)
if cornerMask.isEmpty {
stripe.cornerRadius = 0.0
stripe.maskedCorners = []
} else {
stripe.cornerRadius = max(0.0, v2TableCornerRadius - effectiveBorderWidth)
stripe.maskedCorners = cornerMask
}
self.contentView.layer.insertSublayer(stripe, at: 0)
self.stripeLayers.append(stripe)
}
if let subLayout = cell.subLayout {
if cell.subLayout != nil {
let v = InstantPageV2View(renderContext: renderContext)
v.update(layout: subLayout, theme: theme, animation: .None)
// The sub-layout items are already offset by cell insets inside the cell frame.
v.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
self.contentView.addSubview(v)
self.cellSubViews.append(v)
}
}
// Rounded outer border. `cornerRadius` rounds the layer's border WITHOUT `masksToBounds`,
// so table contents (cell fills, text) stay unclipped only the border visual is rounded.
// Replaces the four straight outer-edge layers the old code appended to `lineLayers`.
self.contentView.layer.cornerRadius = v2TableCornerRadius
self.contentView.layer.borderColor = item.borderColor.cgColor
self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0
// Inner grid lines (between cells).
if item.bordered {
for r in item.horizontalLines + item.verticalLines {
for _ in item.horizontalLines + item.verticalLines {
let line = CALayer()
line.backgroundColor = item.borderColor.cgColor
line.frame = r.offsetBy(dx: 0.0, dy: gridOffsetY)
self.contentView.layer.addSublayer(line)
self.lineLayers.append(line)
}
}
self.update(item: item, theme: theme)
}
@available(*, unavailable)
@ -2044,8 +1997,8 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
self.item = item
self.scrollView.frame = CGRect(origin: .zero, size: item.frame.size)
self.scrollView.contentSize = CGSize(width: item.contentSize.width + item.contentInset, height: item.contentSize.height)
self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width + item.contentInset > item.frame.width
self.scrollView.contentSize = CGSize(width: item.contentSize.width + item.contentInset * 2.0, height: item.contentSize.height)
self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width + item.contentInset * 2.0 > item.frame.width
self.contentView.frame = CGRect(x: item.contentInset, y: 0.0, width: item.contentSize.width, height: item.contentSize.height)
// Forward updates to nested V2 sub-layouts (title + each cell). Recursive update
@ -2093,10 +2046,15 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
}
}
// Inner line layers refresh color in place. (`lineLayers` now holds only inner grid
// Inner line layers refresh colour AND frame in place. (`lineLayers` holds only inner grid
// lines; the outer border is the contentView layer's own rounded border, refreshed below.)
for line in self.lineLayers {
// Frames are set here (not in init) so reuse with a different grid re-positions the lines.
let lineRects = item.horizontalLines + item.verticalLines
for (i, line) in self.lineLayers.enumerated() {
line.backgroundColor = item.borderColor.cgColor
if i < lineRects.count {
line.frame = lineRects[i].offsetBy(dx: 0.0, dy: gridOffsetY)
}
}
// Rounded outer border refresh radius/color/width (theme or `bordered` flag may change).
@ -2276,12 +2234,12 @@ final class InstantPageV2FormulaView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2FormulaItem
var itemFrame: CGRect { return self.item.frame }
init(item: InstantPageV2FormulaItem) {
init(item: InstantPageV2FormulaItem, theme: InstantPageTheme) {
self.item = item
super.init(frame: item.frame)
self.backgroundColor = .clear
self.isOpaque = false
self.buildContents()
self.backgroundColor = .clear // structural
self.isOpaque = false // structural
self.update(item: item, theme: theme)
}
@available(*, unavailable)
@ -2291,7 +2249,8 @@ final class InstantPageV2FormulaView: UIView, InstantPageItemView {
let _ = theme
self.item = item
// Image content and scroll/non-scroll shape may change with width; rebuild.
// Image content and scroll/non-scroll shape may change with width; rebuild. On the first
// call (from init) there is nothing to tear down, so this collapses to a plain build.
for sub in self.subviews { sub.removeFromSuperview() }
if let sublayers = self.layer.sublayers {
for layer in sublayers { layer.removeFromSuperlayer() }

View file

@ -320,7 +320,7 @@ public struct InstantPageV2TableItem {
public let titleSubLayout: InstantPageV2Layout?
public let titleFrame: CGRect?
public let contentSize: CGSize // grid intrinsic size; may exceed frame.width scroll
public let contentInset: CGFloat // left inset (= page horizontalInset) baked into the scroll content width by the renderer
public let contentInset: CGFloat // page horizontalInset; the renderer shifts the grid right by it and pads the scroll content by it on BOTH sides
public let cells: [InstantPageV2TableCell]
public let horizontalLines: [CGRect]
public let verticalLines: [CGRect]
@ -1112,10 +1112,14 @@ private func layoutTable(
var minCellWidth: CGFloat = 1.0
var maxCellWidth: CGFloat = 1.0
if let text = cell.text {
let attrStr = attributedStringForRichText(text, styleStack: styleStack)
// Mirror V1 (`InstantPageTableItem.layoutTableItem`): `attributedStringForRichText`'s
// boundingWidth sizes inline attachments to `cellWidthLimit - totalCellPadding`, while
// the line-break budget passed to `layoutTextItem` is the full `cellWidthLimit`. (V1
// subtracts `totalCellPadding` only on the attribute-string arg, not the layout arg.)
let attrStr = attributedStringForRichText(text, styleStack: styleStack, boundingWidth: cellWidthLimit - totalCellPadding)
if let shortestItem = layoutTextItem(
attrStr,
boundingWidth: cellWidthLimit - totalCellPadding,
boundingWidth: cellWidthLimit,
offset: CGPoint(),
minimizeWidth: true,
fitToWidth: context.fitToWidth,
@ -1125,7 +1129,7 @@ private func layoutTable(
}
if let longestItem = layoutTextItem(
attrStr,
boundingWidth: cellWidthLimit - totalCellPadding,
boundingWidth: cellWidthLimit,
offset: CGPoint(),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
@ -1546,7 +1550,7 @@ private func layoutTable(
// The table item frame spans the full visible bubble interior (`boundingWidth`); the scroll
// viewport equals what is actually visible. contentSize.width is the intrinsic grid width
// (may exceed frame.width horizontal scroll); the renderer adds the left inset on top.
// (may exceed frame.width horizontal scroll); the renderer adds the inset on both sides.
let tableFrame = CGRect(x: 0.0, y: 0.0,
width: boundingWidth,
height: totalHeight + (titleFrame?.height ?? 0.0))

View file

@ -137,11 +137,10 @@ final class InstantPageV2MediaImageView: UIView, InstantPageItemView {
)
super.init(frame: item.frame)
self.backgroundColor = .clear
self.layer.cornerRadius = item.cornerRadius
self.clipsToBounds = item.cornerRadius > 0.0
self.addSubview(self.wrappedNode.view)
wrapperRef.view = self
self.backgroundColor = .clear // structural
self.addSubview(self.wrappedNode.view) // structural
wrapperRef.view = self // structural: back-reference for the openMedia closure
self.update(item: item, theme: theme, renderContext: renderContext)
}
@available(*, unavailable)
@ -193,11 +192,10 @@ final class InstantPageV2MediaVideoView: UIView, InstantPageItemView {
)
super.init(frame: item.frame)
self.backgroundColor = .clear
self.layer.cornerRadius = item.cornerRadius
self.clipsToBounds = item.cornerRadius > 0.0
self.addSubview(self.wrappedNode.view)
wrapperRef.view = self
self.backgroundColor = .clear // structural
self.addSubview(self.wrappedNode.view) // structural
wrapperRef.view = self // structural: back-reference for the openMedia closure
self.update(item: item, theme: theme, renderContext: renderContext)
}
@available(*, unavailable)
@ -249,11 +247,10 @@ final class InstantPageV2MediaMapView: UIView, InstantPageItemView {
)
super.init(frame: item.frame)
self.backgroundColor = .clear
self.layer.cornerRadius = item.cornerRadius
self.clipsToBounds = item.cornerRadius > 0.0
self.addSubview(self.wrappedNode.view)
wrapperRef.view = self
self.backgroundColor = .clear // structural
self.addSubview(self.wrappedNode.view) // structural
wrapperRef.view = self // structural: back-reference for the openMedia closure
self.update(item: item, theme: theme, renderContext: renderContext)
}
@available(*, unavailable)
@ -305,11 +302,10 @@ final class InstantPageV2MediaCoverImageView: UIView, InstantPageItemView {
)
super.init(frame: item.frame)
self.backgroundColor = .clear
self.layer.cornerRadius = item.cornerRadius
self.clipsToBounds = item.cornerRadius > 0.0
self.addSubview(self.wrappedNode.view)
wrapperRef.view = self
self.backgroundColor = .clear // structural
self.addSubview(self.wrappedNode.view) // structural
wrapperRef.view = self // structural: back-reference for the openMedia closure
self.update(item: item, theme: theme, renderContext: renderContext)
}
@available(*, unavailable)