Render and play InstantPageBlock.audio in the InstantPage V2 renderer
(rich-data message bubbles + the rich send preview), where audio was
previously an inert grey placeholder.
- New InstantPageV2AudioContentNode replicates the standard music message
bubble (ChatMessageInteractiveFileNode's music layout): a SemanticStatusNode
control (album art via playerAlbumArt + play/pause) with a download/progress
overlay, title + "duration · performer" lines, exact fonts/colors from
theme.chat.message.{incoming|outgoing}. Tap is driven by a
UITapGestureRecognizer (ASControl .touchUpInside is cancelled by the chat
ListView's gesture system). V1's InstantPageAudioNode is unchanged.
- Playback runs on InstantPageMediaPlaylist with a discriminated, message-scoped
InstantPageMediaPlaylistId (.instantPage / .richMessage) so concurrent
rich-message audio bubbles don't collide; the big control's play/pause comes
from filteredPlaylistState, the overlay's download/progress from
messageMediaFileStatus, and fetch goes through the fetch manager.
- Rich-message audio fetches via a MessageReference (threaded through the V2
render context) instead of the synthesized webpage; FetchedMediaResource's
.message revalidation arm now also searches RichTextMessageAttribute instant
pages, so a stale instant-page audio/image reference can recover. Corrected a
dormant inverted InstantPagePlaylistLocation.isEqual.
- New .mediaAudio laid-out item + layout/reveal-cost arms; the audio block lays
out full-width at the file node's music normHeight.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2353 lines
107 KiB
Swift
2353 lines
107 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import CheckNode
|
|
import SwiftSignalKit
|
|
import TelegramCore
|
|
import TelegramPresentationData
|
|
import TelegramUIPreferences
|
|
import AccountContext
|
|
import GalleryUI
|
|
import ComponentFlow
|
|
import TextFormat
|
|
import EmojiTextAttachmentView
|
|
import AnimationCache
|
|
import MultiAnimationRenderer
|
|
import InvisibleInkDustNode
|
|
import ShimmeringMask
|
|
|
|
// MARK: - Stable item identity (for view reuse on re-layouts)
|
|
|
|
/// Stable identity for an `InstantPageV2LaidOutItem` across `update()` calls. The renderer
|
|
/// uses this to harvest existing item views and reuse them when the new layout still has
|
|
/// an item with the same id — preventing the media wrappers from torching their fetch
|
|
/// signals + image content on every chat-bubble re-apply.
|
|
///
|
|
/// Media items use their `media.index` (already unique within a page and used as the
|
|
/// gallery registry key). Details items use their `details.index`. Other items have no
|
|
/// intrinsic identity, so the renderer assigns them a `(case-tag, positional-index-in-items)`
|
|
/// pair.
|
|
public enum InstantPageV2StableItemId: Hashable {
|
|
case media(Int) // media.index (4 media cases share this namespace)
|
|
case details(Int) // details.index
|
|
case thinking(Int) // thinking-block sequence index (own namespace)
|
|
case positional(InstantPageV2ItemKind, Int) // (caseTag, items-array position)
|
|
}
|
|
|
|
public enum InstantPageV2ItemKind: Hashable {
|
|
case text, codeBlock, divider, listMarker, blockQuoteBar, shape, mediaPlaceholder, table, anchor, formula, slideshow
|
|
}
|
|
|
|
// MARK: - Render context
|
|
|
|
/// Bundle of render-time dependencies required to display real media inside an InstantPage V2
|
|
/// view. Tied to an `InstantPageV2View` for the view's lifetime — if any field would change
|
|
/// (typically because the bubble was recycled with a different webpage), the caller must
|
|
/// rebuild the V2View with a fresh render context.
|
|
///
|
|
/// `renderContext == nil` is permitted: the V2View falls back to grey-placeholder rendering
|
|
/// for the four media kinds (image/video/map/coverImage). This keeps the existing zero-arg
|
|
/// `InstantPageV2View()` constructor usable.
|
|
public final class InstantPageV2RenderContext {
|
|
public let context: AccountContext
|
|
public private(set) var webpage: TelegramMediaWebpage
|
|
public let sourceLocation: InstantPageSourceLocation
|
|
public let imageReference: (TelegramMediaImage) -> ImageMediaReference
|
|
public let fileReference: (TelegramMediaFile) -> FileMediaReference
|
|
public let present: (ViewController, Any?) -> Void
|
|
public let push: (ViewController) -> Void
|
|
public let openUrl: (InstantPageUrlItem) -> Void
|
|
public let baseNavigationController: () -> NavigationController?
|
|
/// A reference to the message hosting this page, when rendered inside a chat bubble. Used to
|
|
/// key audio playback per message (`.richMessage(message.id)`) AND to fetch audio files via a
|
|
/// message reference (so a stale file reference can revalidate); `nil` in the send preview,
|
|
/// which falls back to the webpage-keyed playlist id + webpage file reference.
|
|
public let message: MessageReference?
|
|
|
|
public init(
|
|
context: AccountContext,
|
|
webpage: TelegramMediaWebpage,
|
|
sourceLocation: InstantPageSourceLocation,
|
|
imageReference: @escaping (TelegramMediaImage) -> ImageMediaReference,
|
|
fileReference: @escaping (TelegramMediaFile) -> FileMediaReference,
|
|
present: @escaping (ViewController, Any?) -> Void,
|
|
push: @escaping (ViewController) -> Void,
|
|
openUrl: @escaping (InstantPageUrlItem) -> Void,
|
|
baseNavigationController: @escaping () -> NavigationController?,
|
|
message: MessageReference?
|
|
) {
|
|
self.context = context
|
|
self.webpage = webpage
|
|
self.sourceLocation = sourceLocation
|
|
self.imageReference = imageReference
|
|
self.fileReference = fileReference
|
|
self.present = present
|
|
self.push = push
|
|
self.openUrl = openUrl
|
|
self.baseNavigationController = baseNavigationController
|
|
self.message = message
|
|
}
|
|
|
|
/// Update the content-bearing fields for a later chunk of the SAME message. Enables the
|
|
/// streaming bubble to reuse one V2View across `stableVersion` bumps instead of rebuilding.
|
|
/// Only `webpage` changes across chunks; the `imageReference`/`fileReference` closures keep
|
|
/// their construction-time `MessageReference` snapshot, which is acceptable because the message
|
|
/// id is stable across chunks (media resolves by id) and streamed AI content carries no media.
|
|
public func updateContent(webpage: TelegramMediaWebpage) {
|
|
self.webpage = webpage
|
|
}
|
|
}
|
|
|
|
// MARK: - Inline image view data
|
|
|
|
private struct InlineImageKey: Hashable {
|
|
let fileId: Int64
|
|
let occurrenceIndex: Int
|
|
}
|
|
|
|
/// Per-inline-image state owned by `InstantPageV2View`.
|
|
/// `textView` is a weak reference back to the parent so `updateImageReveal` can
|
|
/// look up the current per-text-view character count.
|
|
private final class InstantPageInlineImageData {
|
|
let view: InstantPageV2InlineImageView
|
|
weak var textView: InstantPageV2TextView?
|
|
var charIndexInItem: Int = 0
|
|
var revealed: Bool = false
|
|
|
|
init(view: InstantPageV2InlineImageView) {
|
|
self.view = view
|
|
}
|
|
}
|
|
|
|
// MARK: - Emoji layer data
|
|
|
|
private final class InstantPageEmojiLayerData {
|
|
let itemLayer: InlineStickerItemLayer
|
|
weak var textView: InstantPageV2TextView?
|
|
var charIndexInItem: Int = 0
|
|
var revealed: Bool = false
|
|
|
|
init(itemLayer: InlineStickerItemLayer) {
|
|
self.itemLayer = itemLayer
|
|
}
|
|
}
|
|
|
|
// MARK: - Public renderer
|
|
|
|
public final class InstantPageV2View: UIView {
|
|
public private(set) var currentLayout: InstantPageV2Layout?
|
|
public private(set) var currentTheme: InstantPageTheme?
|
|
|
|
/// Invoked when a details title is tapped. Bubble routes to its expand-state mutation + requestUpdate.
|
|
public var detailsTapped: ((_ index: Int) -> Void)?
|
|
|
|
var itemViews: [InstantPageItemView] = []
|
|
private var itemViewStableIds: [InstantPageV2StableItemId] = []
|
|
|
|
public let renderContext: InstantPageV2RenderContext?
|
|
|
|
private var inlineStickerItemLayers: [InlineStickerItemLayer.Key: InstantPageEmojiLayerData] = [:]
|
|
private var inlineImageViews: [InlineImageKey: InstantPageInlineImageData] = [:]
|
|
private var emojiEnableLooping: Bool = true
|
|
|
|
/// Scroll-visibility rect in this view's coordinate space; gates emoji animation looping.
|
|
/// `nil` means "not visible" → emoji don't animate. The root rect is set by the bubble and
|
|
/// propagated down the nested tree (details/table) by `propagateVisibilityRect`.
|
|
public var visibilityRect: CGRect? {
|
|
didSet {
|
|
if oldValue != self.visibilityRect {
|
|
self.updateEmojiVisibility()
|
|
}
|
|
}
|
|
}
|
|
public private(set) var displayContentsUnderSpoilers: Bool = false
|
|
|
|
// Weak references to every media wrapper in the tree, keyed by `InstantPageMedia.index`.
|
|
// Used by `transitionArgsFor` and `applyHiddenMedia` so the gallery transition + hidden-source
|
|
// state can find a wrapper without walking the view hierarchy. Nested V2Views (details body,
|
|
// table cells) forward their registrations to the root via `rootMediaRegistryHost`.
|
|
var mediaRegistry: [Int: Weak<UIView>] = [:]
|
|
|
|
// Pointer to the root V2View's registry host. The root sets this to `self`; nested views
|
|
// inherit it via `propagateRegistryHost(to:)` in `update(layout:theme:animation:)`.
|
|
weak var rootMediaRegistryHost: InstantPageV2View?
|
|
|
|
var effectiveRegistryHost: InstantPageV2View {
|
|
return self.rootMediaRegistryHost ?? self
|
|
}
|
|
|
|
/// Walks the `rootMediaRegistryHost` chain transitively until it finds a self-referencing
|
|
/// host (the true root). Necessary because nested details blocks can leave an inner body's
|
|
/// `rootMediaRegistryHost` pointing at an intermediate body rather than the outer root —
|
|
/// `propagateRegistryHost(to:)` only walks one hop, so the chain must be followed at lookup.
|
|
var trueRegistryRoot: InstantPageV2View {
|
|
var host: InstantPageV2View = self
|
|
while let next = host.rootMediaRegistryHost, next !== host {
|
|
host = next
|
|
}
|
|
return host
|
|
}
|
|
|
|
public init(renderContext: InstantPageV2RenderContext?) {
|
|
self.renderContext = renderContext
|
|
super.init(frame: .zero)
|
|
self.backgroundColor = .clear
|
|
self.isOpaque = false
|
|
self.rootMediaRegistryHost = self
|
|
}
|
|
|
|
public convenience init() {
|
|
self.init(renderContext: nil)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
/// Rebuilds the child view hierarchy from `layout`. The caller is responsible for
|
|
/// sizing `self.frame` to `layout.contentSize`; this method does not touch its own frame.
|
|
///
|
|
/// Reuse pass: existing item views are harvested into a `[stableId: view]` map keyed
|
|
/// off `itemViewStableIds`. For each new item we look up its stable id; on a hit (and
|
|
/// matching concrete view class) the existing view is reused via its typed
|
|
/// `update(item:theme:[renderContext:])`, on a miss we fall back to `makeItemView`. Un-reused
|
|
/// views are removed from the superview at the end. This preserves the four media wrappers
|
|
/// (and any nested V2Views inside details/table) across chat-bubble re-applies, which would
|
|
/// otherwise torch in-flight image fetches on every list update.
|
|
public func update(
|
|
layout: InstantPageV2Layout,
|
|
theme: InstantPageTheme,
|
|
animation: ListViewItemUpdateAnimation
|
|
) {
|
|
// Build map of existing views by stable id.
|
|
var oldViewsById: [InstantPageV2StableItemId: InstantPageItemView] = [:]
|
|
for (oldIndex, oldId) in self.itemViewStableIds.enumerated() {
|
|
oldViewsById[oldId] = self.itemViews[oldIndex]
|
|
}
|
|
|
|
var newItemViews: [InstantPageItemView] = []
|
|
var newStableIds: [InstantPageV2StableItemId] = []
|
|
var reusedIds: Set<InstantPageV2StableItemId> = []
|
|
|
|
// Two independent position counters so thinking-block churn never renumbers content
|
|
// blocks' stable ids (requirement: adding/removing a thinking block must not affect other
|
|
// blocks). Content items are numbered ignoring thinking items; thinking items get their
|
|
// own .thinking(index) namespace.
|
|
var contentPosition = 0
|
|
var thinkingPosition = 0
|
|
for item in layout.items {
|
|
let id: InstantPageV2StableItemId
|
|
if case .thinking = item {
|
|
id = InstantPageV2View.stableId(for: item, atPosition: thinkingPosition)
|
|
thinkingPosition += 1
|
|
} else {
|
|
id = InstantPageV2View.stableId(for: item, atPosition: contentPosition)
|
|
contentPosition += 1
|
|
}
|
|
|
|
if let existing = oldViewsById[id], let reusedView = self.reuse(existingView: existing, for: item, theme: theme, animation: animation) {
|
|
let newFrame = InstantPageV2View.actualFrame(forItem: item) // parent positions child
|
|
if animation.isAnimated && reusedView.frame != newFrame {
|
|
// A collapsing details view keeps its body alive; remove it once this
|
|
// frame-shrink (the clip that hides it) finishes — see finalizePendingCollapse().
|
|
let detailsView = reusedView as? InstantPageV2DetailsView
|
|
animation.animator.updateFrame(layer: reusedView.layer, frame: newFrame, completion: { [weak detailsView] _ in
|
|
detailsView?.finalizePendingCollapse()
|
|
})
|
|
} else {
|
|
reusedView.frame = newFrame
|
|
(reusedView as? InstantPageV2DetailsView)?.finalizePendingCollapse()
|
|
}
|
|
newItemViews.append(reusedView)
|
|
newStableIds.append(id)
|
|
reusedIds.insert(id)
|
|
// Already in subviews from the previous update; just keep it.
|
|
} else {
|
|
guard let newView = self.makeItemView(for: item, theme: theme) else { continue }
|
|
newItemViews.append(newView)
|
|
newStableIds.append(id)
|
|
self.addSubview(newView)
|
|
self.propagateRegistryHost(to: newView)
|
|
}
|
|
}
|
|
|
|
// Remove views that weren't reused.
|
|
for (id, view) in oldViewsById where !reusedIds.contains(id) {
|
|
view.removeFromSuperview()
|
|
}
|
|
|
|
// Z-order: bring the reused views to the front in declaration order so the
|
|
// sublayer/subview stack matches `layout.items` order.
|
|
for view in newItemViews {
|
|
self.bringSubviewToFront(view)
|
|
}
|
|
|
|
self.itemViews = newItemViews
|
|
self.itemViewStableIds = newStableIds
|
|
self.currentLayout = layout
|
|
self.currentTheme = theme
|
|
self.updateInlineImages()
|
|
self.updateInlineEmoji()
|
|
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 init→update→updateSpoiler path, so we only push the external animation
|
|
// setting here; its didSet rebuilds the dust if the value actually changed.
|
|
textView.enableSpoilerAnimations = enableSpoilerAnimations
|
|
}
|
|
}
|
|
// Force the current reveal state (true OR false) onto every text view every layout, so a
|
|
// positionally-reused text view cannot retain a stale reveal flag from prior content.
|
|
self.setDisplayContentsUnderSpoilers(self.displayContentsUnderSpoilers, atLocation: nil, animated: false)
|
|
}
|
|
|
|
func updateInlineEmoji() {
|
|
guard let rc = self.renderContext else { return }
|
|
let context = rc.context
|
|
let cache = context.animationCache
|
|
let renderer = context.animationRenderer
|
|
self.emojiEnableLooping = context.sharedContext.energyUsageSettings.loopEmoji
|
|
|
|
var nextIndexById: [Int64: Int] = [:]
|
|
var validIds: [InlineStickerItemLayer.Key] = []
|
|
|
|
for view in self.itemViews {
|
|
// Top-level `.text` items host their emoji directly. The thinking block hosts emoji on
|
|
// its shimmer-wrapped inner text view, which the page never sees as a top-level item —
|
|
// so without this it is skipped and the emoji never get layers (invisible). Nested V2
|
|
// sub-layouts (details bodies, table cells) instead run their own updateInlineEmoji.
|
|
let textView: InstantPageV2TextView
|
|
if let topLevelTextView = view as? InstantPageV2TextView {
|
|
textView = topLevelTextView
|
|
} else if let thinkingView = view as? InstantPageV2ThinkingView {
|
|
textView = thinkingView.textView
|
|
} else {
|
|
continue
|
|
}
|
|
let textItem = textView.item.textItem
|
|
let boundsWidth = textItem.frame.size.width
|
|
for line in textItem.lines {
|
|
if line.emojiItems.isEmpty { continue }
|
|
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
|
|
for emojiItem in line.emojiItems {
|
|
let index = nextIndexById[emojiItem.emoji.fileId] ?? 0
|
|
nextIndexById[emojiItem.emoji.fileId] = index + 1
|
|
let id = InlineStickerItemLayer.Key(id: emojiItem.emoji.fileId, index: index)
|
|
validIds.append(id)
|
|
|
|
let itemSize = emojiItem.frame.width
|
|
let localX = lineFrame.minX + (emojiItem.frame.minX - line.frame.minX)
|
|
let itemFrame = CGRect(
|
|
x: localX + v2TextViewClippingInset,
|
|
y: emojiItem.frame.minY + v2TextViewClippingInset,
|
|
width: itemSize,
|
|
height: itemSize
|
|
)
|
|
|
|
var textColor: UIColor?
|
|
if emojiItem.range.location < textItem.attributedString.length {
|
|
textColor = textItem.attributedString.attribute(.foregroundColor, at: emojiItem.range.location, effectiveRange: nil) as? UIColor
|
|
}
|
|
|
|
let data: InstantPageEmojiLayerData
|
|
if let existing = self.inlineStickerItemLayers[id] {
|
|
data = existing
|
|
if data.itemLayer.superlayer !== textView.emojiContainerView.layer {
|
|
textView.emojiContainerView.layer.addSublayer(data.itemLayer)
|
|
}
|
|
} else {
|
|
let pointSize = floor(itemSize * 1.3)
|
|
let layer = InlineStickerItemLayer(
|
|
context: context,
|
|
userLocation: .other,
|
|
attemptSynchronousLoad: false,
|
|
emoji: emojiItem.emoji,
|
|
file: emojiItem.emoji.file,
|
|
cache: cache,
|
|
renderer: renderer,
|
|
placeholderColor: UIColor(white: 0.5, alpha: 0.3),
|
|
pointSize: CGSize(width: pointSize, height: pointSize),
|
|
dynamicColor: textColor
|
|
)
|
|
layer.opacity = 0.0
|
|
data = InstantPageEmojiLayerData(itemLayer: layer)
|
|
self.inlineStickerItemLayers[id] = data
|
|
textView.emojiContainerView.layer.addSublayer(layer)
|
|
}
|
|
|
|
data.itemLayer.dynamicColor = textColor
|
|
data.itemLayer.frame = itemFrame
|
|
data.textView = textView
|
|
data.charIndexInItem = emojiItem.range.location
|
|
}
|
|
}
|
|
}
|
|
|
|
var removeKeys: [InlineStickerItemLayer.Key] = []
|
|
for (key, data) in self.inlineStickerItemLayers where !validIds.contains(key) {
|
|
removeKeys.append(key)
|
|
data.itemLayer.removeFromSuperlayer()
|
|
}
|
|
for key in removeKeys {
|
|
self.inlineStickerItemLayers.removeValue(forKey: key)
|
|
}
|
|
|
|
self.updateEmojiReveal(animated: false)
|
|
}
|
|
|
|
func updateInlineImages() {
|
|
guard let renderContext = self.renderContext,
|
|
let layout = self.currentLayout,
|
|
let theme = self.currentTheme
|
|
else { return }
|
|
let context = renderContext.context
|
|
|
|
var nextIndexById: [Int64: Int] = [:]
|
|
var validKeys: Set<InlineImageKey> = []
|
|
|
|
for view in self.itemViews {
|
|
// Same nesting as updateInlineEmoji: top-level `.text` items host their inline images
|
|
// directly; the thinking block hosts them on its shimmer-wrapped inner text view, which
|
|
// the page never sees as a top-level item. Nested V2 sub-layouts run their own pass.
|
|
let textView: InstantPageV2TextView
|
|
if let topLevelTextView = view as? InstantPageV2TextView {
|
|
textView = topLevelTextView
|
|
} else if let thinkingView = view as? InstantPageV2ThinkingView {
|
|
textView = thinkingView.textView
|
|
} else {
|
|
continue
|
|
}
|
|
let textItem = textView.item.textItem
|
|
let boundsWidth = textItem.frame.size.width
|
|
for line in textItem.lines {
|
|
if line.imageItems.isEmpty { continue }
|
|
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
|
|
for imageItem in line.imageItems {
|
|
let index = nextIndexById[imageItem.id.id] ?? 0
|
|
nextIndexById[imageItem.id.id] = index + 1
|
|
let key = InlineImageKey(fileId: imageItem.id.id, occurrenceIndex: index)
|
|
guard let media = layout.media[imageItem.id] else { continue }
|
|
validKeys.insert(key)
|
|
|
|
let localX = lineFrame.minX + (imageItem.frame.minX - line.frame.minX)
|
|
let itemFrame = CGRect(
|
|
x: localX + v2TextViewClippingInset,
|
|
y: imageItem.frame.minY + v2TextViewClippingInset,
|
|
width: imageItem.frame.width,
|
|
height: imageItem.frame.height
|
|
)
|
|
|
|
let data: InstantPageInlineImageData
|
|
if let existing = self.inlineImageViews[key] {
|
|
data = existing
|
|
if data.view.superview !== textView.imageContainerView {
|
|
textView.imageContainerView.addSubview(data.view)
|
|
}
|
|
} else {
|
|
let newView = InstantPageV2InlineImageView(
|
|
media: media,
|
|
webpage: layout.webpage,
|
|
frame: itemFrame,
|
|
context: context,
|
|
userLocation: .other,
|
|
theme: theme
|
|
)
|
|
// Image starts hidden; updateImageReveal pops it in when the streaming
|
|
// cursor crosses its char-index. For non-streaming pages (no
|
|
// `TypingDraftMessageAttribute`), `revealed = true` (via the fallback in
|
|
// updateImageReveal) sets alpha = 1 unconditionally on the next call.
|
|
newView.alpha = 0.0
|
|
data = InstantPageInlineImageData(view: newView)
|
|
self.inlineImageViews[key] = data
|
|
textView.imageContainerView.addSubview(newView)
|
|
}
|
|
|
|
data.view.frame = itemFrame
|
|
data.textView = textView
|
|
data.charIndexInItem = imageItem.range.location
|
|
}
|
|
}
|
|
}
|
|
|
|
var removeKeys: [InlineImageKey] = []
|
|
for (key, data) in self.inlineImageViews where !validKeys.contains(key) {
|
|
removeKeys.append(key)
|
|
data.view.removeFromSuperview()
|
|
}
|
|
for key in removeKeys {
|
|
self.inlineImageViews.removeValue(forKey: key)
|
|
}
|
|
|
|
self.updateImageReveal(animated: false)
|
|
}
|
|
|
|
func updateEmojiReveal(animated: Bool) {
|
|
for (_, data) in self.inlineStickerItemLayers {
|
|
let revealed: Bool
|
|
if let textView = data.textView, let count = textView.currentRevealCharacterCount {
|
|
revealed = data.charIndexInItem < count
|
|
} else {
|
|
revealed = true
|
|
}
|
|
if data.revealed == revealed {
|
|
continue
|
|
}
|
|
data.revealed = revealed
|
|
if revealed {
|
|
if animated {
|
|
data.itemLayer.opacity = 1.0
|
|
let opacityAnim = CABasicAnimation(keyPath: "opacity")
|
|
opacityAnim.fromValue = 0.0
|
|
opacityAnim.toValue = 1.0
|
|
opacityAnim.duration = 0.2
|
|
data.itemLayer.add(opacityAnim, forKey: "emojiRevealOpacity")
|
|
let scaleAnim = CABasicAnimation(keyPath: "transform.scale")
|
|
scaleAnim.fromValue = 0.1
|
|
scaleAnim.toValue = 1.0
|
|
scaleAnim.duration = 0.2
|
|
scaleAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
|
data.itemLayer.add(scaleAnim, forKey: "emojiRevealScale")
|
|
} else {
|
|
data.itemLayer.opacity = 1.0
|
|
}
|
|
} else {
|
|
data.itemLayer.opacity = 0.0
|
|
}
|
|
}
|
|
self.updateEmojiVisibility()
|
|
}
|
|
|
|
func updateImageReveal(animated: Bool) {
|
|
for (_, data) in self.inlineImageViews {
|
|
let revealed: Bool
|
|
if let textView = data.textView, let count = textView.currentRevealCharacterCount {
|
|
revealed = data.charIndexInItem < count
|
|
} else {
|
|
revealed = true
|
|
}
|
|
if data.revealed == revealed {
|
|
continue
|
|
}
|
|
data.revealed = revealed
|
|
if revealed {
|
|
if animated {
|
|
data.view.layer.opacity = 1.0
|
|
let opacityAnim = CABasicAnimation(keyPath: "opacity")
|
|
opacityAnim.fromValue = 0.0
|
|
opacityAnim.toValue = 1.0
|
|
opacityAnim.duration = 0.2
|
|
data.view.layer.add(opacityAnim, forKey: "inlineImageRevealOpacity")
|
|
let scaleAnim = CABasicAnimation(keyPath: "transform.scale")
|
|
scaleAnim.fromValue = 0.1
|
|
scaleAnim.toValue = 1.0
|
|
scaleAnim.duration = 0.2
|
|
scaleAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
|
data.view.layer.add(scaleAnim, forKey: "inlineImageRevealScale")
|
|
} else {
|
|
data.view.layer.opacity = 1.0
|
|
}
|
|
} else {
|
|
data.view.layer.opacity = 0.0
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateEmojiVisibility() {
|
|
for (_, data) in self.inlineStickerItemLayers {
|
|
let onScreen: Bool
|
|
if let visibilityRect = self.visibilityRect, let textView = data.textView {
|
|
let rectInSelf = textView.convert(data.itemLayer.frame, to: self)
|
|
onScreen = rectInSelf.intersects(visibilityRect)
|
|
} else {
|
|
// No visibility rect == not tracked / off-screen → don't animate. The root view's
|
|
// rect is propagated down the nested tree (details bodies, table cells/title) by
|
|
// `propagateVisibilityRect`, so a nil here genuinely means "not visible".
|
|
onScreen = false
|
|
}
|
|
data.itemLayer.isVisibleForAnimations = self.emojiEnableLooping && data.revealed && onScreen
|
|
}
|
|
self.propagateVisibilityRect()
|
|
}
|
|
|
|
// Pushes this view's `visibilityRect` down into every nested V2 view (details body, table
|
|
// title + cells), converted into each child's coordinate space. Each child's `visibilityRect`
|
|
// didSet re-runs `updateEmojiVisibility`, which propagates one level further — so a single
|
|
// root assignment fans out across the whole tree.
|
|
private func propagateVisibilityRect() {
|
|
for view in self.itemViews {
|
|
for nested in InstantPageV2View.nestedV2Views(of: view) {
|
|
let childRect = self.visibilityRect.map { self.convert($0, to: nested) }
|
|
if nested.visibilityRect != childRect {
|
|
nested.visibilityRect = childRect
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reveals (or re-hides) every spoiler in this view and nested details/table V2 views.
|
|
/// `location` (this view's coords) drives the dust explosion origin for whichever text view
|
|
/// contains it; the rest just toggle. Single-flag behavior mirrors ChatMessageTextBubbleContentNode.
|
|
public func setDisplayContentsUnderSpoilers(_ value: Bool, atLocation location: CGPoint?, animated: Bool) {
|
|
self.displayContentsUnderSpoilers = value
|
|
for view in self.itemViews {
|
|
if let textView = view as? InstantPageV2TextView {
|
|
let childLocation = location.map { self.convert($0, to: textView) }
|
|
textView.setDisplayContentsUnderSpoilers(value, atLocation: childLocation, animated: animated)
|
|
}
|
|
for nested in InstantPageV2View.nestedV2Views(of: view) {
|
|
let childLocation = location.map { self.convert($0, to: nested) }
|
|
nested.setDisplayContentsUnderSpoilers(value, atLocation: childLocation, animated: animated)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func nestedV2Views(of view: InstantPageItemView) -> [InstantPageV2View] {
|
|
if let detailsView = view as? InstantPageV2DetailsView {
|
|
return detailsView.bodyView.map { [$0] } ?? []
|
|
} else if let tableView = view as? InstantPageV2TableView {
|
|
var result: [InstantPageV2View] = []
|
|
if let titleSubView = tableView.titleSubView {
|
|
result.append(titleSubView)
|
|
}
|
|
result.append(contentsOf: tableView.cellSubViews)
|
|
return result
|
|
}
|
|
return []
|
|
}
|
|
|
|
/// Returns the input view typed-updated against `item`, or `nil` if the existing view's
|
|
/// concrete class doesn't match the item's case (e.g. a `text` slot has been replaced by
|
|
/// a `divider` in the new layout). Caller falls back to `makeItemView`.
|
|
private func reuse(existingView: InstantPageItemView, for item: InstantPageV2LaidOutItem, theme: InstantPageTheme, animation: ListViewItemUpdateAnimation) -> InstantPageItemView? {
|
|
switch item {
|
|
case let .text(text):
|
|
guard let v = existingView as? InstantPageV2TextView else { return nil }
|
|
v.update(item: text, theme: theme)
|
|
return v
|
|
case let .codeBlock(block):
|
|
guard let v = existingView as? InstantPageV2CodeBlockView else { return nil }
|
|
v.update(item: block, theme: theme)
|
|
return v
|
|
case let .divider(divider):
|
|
guard let v = existingView as? InstantPageV2DividerView else { return nil }
|
|
v.update(item: divider, theme: theme)
|
|
return v
|
|
case let .listMarker(marker):
|
|
guard let v = existingView as? InstantPageV2ListMarkerView else { return nil }
|
|
v.update(item: marker, theme: theme)
|
|
return v
|
|
case let .blockQuoteBar(bar):
|
|
guard let v = existingView as? InstantPageV2BlockQuoteBarView else { return nil }
|
|
v.update(item: bar, theme: theme)
|
|
return v
|
|
case let .shape(shape):
|
|
guard let v = existingView as? InstantPageV2ShapeView else { return nil }
|
|
v.update(item: shape, theme: theme)
|
|
return v
|
|
case let .mediaPlaceholder(media):
|
|
guard let v = existingView as? InstantPageV2MediaPlaceholderView else { return nil }
|
|
v.update(item: media, theme: theme)
|
|
return v
|
|
case let .details(details):
|
|
guard let v = existingView as? InstantPageV2DetailsView else { return nil }
|
|
v.update(item: details, theme: theme, renderContext: self.renderContext, animation: animation)
|
|
return v
|
|
case let .table(table):
|
|
guard let v = existingView as? InstantPageV2TableView else { return nil }
|
|
v.update(item: table, theme: theme)
|
|
return v
|
|
case let .anchor(anchor):
|
|
guard let v = existingView as? InstantPageV2AnchorView else { return nil }
|
|
v.update(item: anchor, theme: theme)
|
|
return v
|
|
case let .formula(formula):
|
|
guard let v = existingView as? InstantPageV2FormulaView else { return nil }
|
|
v.update(item: formula, theme: theme)
|
|
return v
|
|
case let .mediaImage(media):
|
|
guard let v = existingView as? InstantPageV2MediaImageView, let rc = self.renderContext else { return nil }
|
|
v.update(item: media, theme: theme, renderContext: rc)
|
|
return v
|
|
case let .mediaVideo(media):
|
|
guard let v = existingView as? InstantPageV2MediaVideoView, let rc = self.renderContext else { return nil }
|
|
v.update(item: media, theme: theme, renderContext: rc)
|
|
return v
|
|
case let .mediaMap(media):
|
|
guard let v = existingView as? InstantPageV2MediaMapView, let rc = self.renderContext else { return nil }
|
|
v.update(item: media, theme: theme, renderContext: rc)
|
|
return v
|
|
case let .mediaCoverImage(media):
|
|
guard let v = existingView as? InstantPageV2MediaCoverImageView, let rc = self.renderContext else { return nil }
|
|
v.update(item: media, theme: theme, renderContext: rc)
|
|
return v
|
|
case let .mediaAudio(media):
|
|
guard let v = existingView as? InstantPageV2MediaAudioView, let rc = self.renderContext else { return nil }
|
|
v.update(item: media, theme: theme, renderContext: rc)
|
|
return v
|
|
case let .thinking(thinking):
|
|
guard let v = existingView as? InstantPageV2ThinkingView else { return nil }
|
|
v.update(item: thinking, theme: theme)
|
|
return v
|
|
case let .slideshow(slideshow):
|
|
guard let v = existingView as? InstantPageV2SlideshowView, let rc = self.renderContext else { return nil }
|
|
v.update(item: slideshow, theme: theme, renderContext: rc)
|
|
return v
|
|
}
|
|
}
|
|
|
|
static func stableId(for item: InstantPageV2LaidOutItem, atPosition position: Int) -> InstantPageV2StableItemId {
|
|
switch item {
|
|
case let .mediaImage(m): return .media(m.media.index)
|
|
case let .mediaVideo(m): return .media(m.media.index)
|
|
case let .mediaMap(m): return .media(m.media.index)
|
|
case let .mediaCoverImage(m): return .media(m.media.index)
|
|
case let .mediaAudio(m): return .media(m.media.index)
|
|
case let .details(d): return .details(d.index)
|
|
case .text: return .positional(.text, position)
|
|
case .codeBlock: return .positional(.codeBlock, position)
|
|
case .divider: return .positional(.divider, position)
|
|
case .listMarker: return .positional(.listMarker, position)
|
|
case .blockQuoteBar: return .positional(.blockQuoteBar, position)
|
|
case .shape: return .positional(.shape, position)
|
|
case .mediaPlaceholder: return .positional(.mediaPlaceholder, position)
|
|
case .table: return .positional(.table, position)
|
|
case .anchor: return .positional(.anchor, position)
|
|
case .formula: return .positional(.formula, position)
|
|
case .thinking: return .thinking(position)
|
|
case .slideshow: return .positional(.slideshow, position)
|
|
}
|
|
}
|
|
|
|
private func propagateRegistryHost(to view: InstantPageItemView) {
|
|
let host = self.effectiveRegistryHost
|
|
if let details = view as? InstantPageV2DetailsView {
|
|
details.forEachSubLayoutView { sub in
|
|
sub.rootMediaRegistryHost = host
|
|
}
|
|
}
|
|
if let table = view as? InstantPageV2TableView {
|
|
table.forEachSubLayoutView { sub in
|
|
sub.rootMediaRegistryHost = host
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Looks up the wrapper view registered under `media.index` and returns gallery transition
|
|
/// arguments backed by its wrapped `InstantPageImageNode`. Returns `nil` if the wrapper is
|
|
/// not currently registered (e.g. the media is inside a collapsed details block).
|
|
func transitionArgsFor(_ media: InstantPageMedia, addToTransitionSurface: @escaping (UIView) -> Void) -> GalleryTransitionArguments? {
|
|
guard let wrapperBox = self.trueRegistryRoot.mediaRegistry[media.index], let wrapper = wrapperBox.value else {
|
|
return nil
|
|
}
|
|
guard let itemView = wrapper as? InstantPageItemView else { return nil }
|
|
guard let transitionNode = itemView.instantPageTransitionNode(for: media) else { return nil }
|
|
return GalleryTransitionArguments(transitionNode: transitionNode, addToTransitionSurface: addToTransitionSurface)
|
|
}
|
|
|
|
/// Forwards a hidden-media tick from the gallery's `hiddenMedia` signal to every registered
|
|
/// wrapper, calling `updateHiddenMedia(media:)` on each wrapped image node.
|
|
func applyHiddenMedia(_ hidden: InstantPageMedia?) {
|
|
for (_, weakBox) in self.trueRegistryRoot.mediaRegistry {
|
|
guard let wrapper = weakBox.value else { continue }
|
|
(wrapper as? InstantPageItemView)?.instantPageUpdateHiddenMedia(hidden)
|
|
}
|
|
}
|
|
|
|
private func makeItemView(for item: InstantPageV2LaidOutItem, theme: InstantPageTheme) -> InstantPageItemView? {
|
|
switch item {
|
|
case let .text(text):
|
|
return InstantPageV2TextView(item: text, theme: theme)
|
|
case let .divider(divider):
|
|
return InstantPageV2DividerView(item: divider, theme: theme)
|
|
case let .anchor(anchor):
|
|
return InstantPageV2AnchorView(item: anchor, theme: theme)
|
|
case let .listMarker(marker):
|
|
return InstantPageV2ListMarkerView(item: marker, theme: theme)
|
|
case let .codeBlock(block):
|
|
return InstantPageV2CodeBlockView(item: block, theme: theme)
|
|
case let .blockQuoteBar(bar):
|
|
return InstantPageV2BlockQuoteBarView(item: bar, theme: theme)
|
|
case let .shape(shape):
|
|
return InstantPageV2ShapeView(item: shape, theme: theme)
|
|
case let .mediaPlaceholder(media):
|
|
return InstantPageV2MediaPlaceholderView(item: media, theme: theme)
|
|
case let .details(details):
|
|
let view = InstantPageV2DetailsView(item: details, theme: theme, renderContext: self.renderContext)
|
|
view.onTitleTapped = { [weak self] index in
|
|
self?.detailsTapped?(index)
|
|
}
|
|
return view
|
|
case let .table(table):
|
|
return InstantPageV2TableView(item: table, theme: theme, renderContext: self.renderContext)
|
|
case let .mediaImage(media):
|
|
if let renderContext = self.renderContext {
|
|
return InstantPageV2MediaImageView(item: media, renderContext: renderContext, theme: theme)
|
|
} else {
|
|
return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme)
|
|
}
|
|
case let .mediaVideo(media):
|
|
if let renderContext = self.renderContext {
|
|
return InstantPageV2MediaVideoView(item: media, renderContext: renderContext, theme: theme)
|
|
} else {
|
|
return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme)
|
|
}
|
|
case let .mediaMap(media):
|
|
if let renderContext = self.renderContext {
|
|
return InstantPageV2MediaMapView(item: media, renderContext: renderContext, theme: theme)
|
|
} else {
|
|
return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme)
|
|
}
|
|
case let .mediaCoverImage(media):
|
|
if let renderContext = self.renderContext {
|
|
return InstantPageV2MediaCoverImageView(item: media, renderContext: renderContext, theme: theme)
|
|
} else {
|
|
return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme)
|
|
}
|
|
case let .mediaAudio(media):
|
|
if let renderContext = self.renderContext {
|
|
return InstantPageV2MediaAudioView(item: media, renderContext: renderContext, theme: theme)
|
|
} else {
|
|
return InstantPageV2MediaPlaceholderView(item: InstantPageV2MediaPlaceholderItem(frame: media.frame, kind: .audio, cornerRadius: 0.0), theme: theme)
|
|
}
|
|
case let .formula(formula):
|
|
return InstantPageV2FormulaView(item: formula, theme: theme)
|
|
case let .thinking(thinking):
|
|
return InstantPageV2ThinkingView(item: thinking, theme: theme)
|
|
case let .slideshow(slideshow):
|
|
if let renderContext = self.renderContext {
|
|
return InstantPageV2SlideshowView(item: slideshow, renderContext: renderContext, theme: theme)
|
|
} else {
|
|
return InstantPageV2MediaPlaceholderView(item: InstantPageV2MediaPlaceholderItem(frame: slideshow.frame, kind: .slideshow, cornerRadius: 0.0), theme: theme)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns the frame the parent should assign to the view for `item`.
|
|
///
|
|
/// For most item types this is `item.frame`. `InstantPageV2TextView` widens its backing store
|
|
/// by `v2TextViewClippingInset` on every side to accommodate glyph overhang and underline
|
|
/// rendering past the text's logical `maxY` — the same inset its `init` applies when
|
|
/// constructing the view. The reuse path must apply the same expansion so that re-layout
|
|
/// (theme change, bubble resize, etc.) does not clip italic glyphs or underlines.
|
|
///
|
|
/// Keep this helper aligned with each view class's init-time frame computation.
|
|
private static func actualFrame(forItem item: InstantPageV2LaidOutItem) -> CGRect {
|
|
switch item {
|
|
case let .text(textItem):
|
|
return textItem.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset)
|
|
default:
|
|
return item.frame
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Placeholder fallbacks for the four typed media items
|
|
//
|
|
// Used by `makeItemView` when `renderContext == nil` (the zero-arg V2View constructor):
|
|
// we still need to emit a sized grey rectangle for image/video/map/coverImage so the
|
|
// surrounding layout doesn't collapse. Each helper synthesizes a placeholder item with
|
|
// the same frame + cornerRadius as the typed item, picking the kind that matches the
|
|
// closest existing placeholder visual.
|
|
|
|
private func placeholderFallback(for item: InstantPageV2MediaImageItem) -> InstantPageV2MediaPlaceholderItem {
|
|
return InstantPageV2MediaPlaceholderItem(frame: item.frame, kind: .image, cornerRadius: item.cornerRadius)
|
|
}
|
|
|
|
private func placeholderFallback(for item: InstantPageV2MediaVideoItem) -> InstantPageV2MediaPlaceholderItem {
|
|
return InstantPageV2MediaPlaceholderItem(frame: item.frame, kind: .video, cornerRadius: item.cornerRadius)
|
|
}
|
|
|
|
private func placeholderFallback(for item: InstantPageV2MediaMapItem) -> InstantPageV2MediaPlaceholderItem {
|
|
return InstantPageV2MediaPlaceholderItem(frame: item.frame, kind: .map, cornerRadius: item.cornerRadius)
|
|
}
|
|
|
|
private func placeholderFallback(for item: InstantPageV2MediaCoverImageItem) -> InstantPageV2MediaPlaceholderItem {
|
|
return InstantPageV2MediaPlaceholderItem(frame: item.frame, kind: .webEmbed, cornerRadius: item.cornerRadius)
|
|
}
|
|
|
|
// MARK: - Item view protocol
|
|
|
|
protocol InstantPageItemView: UIView {
|
|
/// Frame in the parent V2 view's coordinate space (== `item.frame`).
|
|
var itemFrame: CGRect { get }
|
|
/// Recursion hook for nested layouts (details body, table cells, table title).
|
|
var subLayoutView: InstantPageV2View? { get }
|
|
/// Gallery open: the transition source for `media` if this view (or a descendant) shows it.
|
|
/// Default nil (non-media views). Media views forward to their wrapped `InstantPageImageNode`;
|
|
/// the slideshow forwards to its matching page.
|
|
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))?
|
|
/// Gallery hidden-media tick: hide/show the source for `media`. Default no-op.
|
|
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?)
|
|
}
|
|
|
|
extension InstantPageItemView {
|
|
var subLayoutView: InstantPageV2View? { return nil }
|
|
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { return nil }
|
|
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) { }
|
|
}
|
|
|
|
// MARK: - Text view (port of V1 InstantPageTextItem.drawInTile)
|
|
|
|
/// Per-side padding applied to `InstantPageV2TextView`'s backing store, beyond the
|
|
/// item's typographic frame. Marker rounded rects extend ±2pt past their run, italic
|
|
/// or accented glyphs can overhang past the line's advance width, and the last line's
|
|
/// underline sits 2pt below `lineFrame.maxY`. The view grows by this amount on each
|
|
/// side and the draw context translates by the same amount so visual position is
|
|
/// unchanged.
|
|
private let v2TextViewClippingInset: CGFloat = 4.0
|
|
|
|
final class InstantPageV2TextView: UIView, InstantPageItemView {
|
|
private(set) var item: InstantPageV2TextItem
|
|
var itemFrame: CGRect { return self.item.frame }
|
|
|
|
private let renderContainer: UIView
|
|
private let renderView: TextRenderView
|
|
/// Sibling of `renderContainer`, holds inline `InstantPageV2InlineImageView`s
|
|
/// owned by the parent `InstantPageV2View`. Sits BELOW `emojiContainerView`
|
|
/// (so a colocated emoji renders above an image) and ABOVE `renderContainer`
|
|
/// (so the reveal mask wipes text without clipping images).
|
|
let imageContainerView: UIView = UIView()
|
|
let emojiContainerView: UIView = UIView()
|
|
let spoilerContainerView: UIView = UIView()
|
|
private var dustNode: InvisibleInkDustNode?
|
|
private var displayContentsUnderSpoilers: Bool = false
|
|
var enableSpoilerAnimations: Bool = true {
|
|
didSet {
|
|
if oldValue != self.enableSpoilerAnimations, let dustNode = self.dustNode {
|
|
self.dustNode = nil
|
|
dustNode.view.removeFromSuperview()
|
|
self.updateSpoiler(animated: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reveal mask state — populated in Task 5.
|
|
private var maxCharacterDrawCount: Int?
|
|
private var previousMaxCharacterDrawCount: Int = 0
|
|
private var revealMaskLayer: SimpleLayer?
|
|
private var revealLineMaskLayers: [SimpleLayer] = []
|
|
private var animatingSnippetLayers: [SnippetLayer] = []
|
|
|
|
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.backgroundColor = .clear
|
|
self.renderContainer.isOpaque = false
|
|
self.addSubview(self.renderContainer)
|
|
self.renderContainer.addSubview(self.renderView)
|
|
self.imageContainerView.isUserInteractionEnabled = false
|
|
self.addSubview(self.imageContainerView)
|
|
self.emojiContainerView.isUserInteractionEnabled = false
|
|
self.addSubview(self.emojiContainerView)
|
|
self.spoilerContainerView.isUserInteractionEnabled = false
|
|
self.addSubview(self.spoilerContainerView)
|
|
self.update(item: item, theme: theme)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
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 = containerBounds
|
|
self.emojiContainerView.frame = containerBounds
|
|
self.spoilerContainerView.frame = containerBounds
|
|
self.renderView.displayContentsUnderSpoilers = self.displayContentsUnderSpoilers
|
|
self.updateSpoiler(animated: false)
|
|
}
|
|
|
|
private func spoilerRectsInContainer() -> [CGRect] {
|
|
let textItem = self.item.textItem
|
|
let boundsWidth = textItem.frame.size.width
|
|
var rects: [CGRect] = []
|
|
for line in textItem.lines {
|
|
if line.spoilerItems.isEmpty { continue }
|
|
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
|
|
for spoiler in line.spoilerItems {
|
|
let localX = lineFrame.minX + (spoiler.frame.minX - line.frame.minX)
|
|
rects.append(CGRect(x: localX + v2TextViewClippingInset, y: spoiler.frame.minY + v2TextViewClippingInset, width: spoiler.frame.width, height: spoiler.frame.height))
|
|
}
|
|
}
|
|
return rects
|
|
}
|
|
|
|
func updateSpoiler(animated: Bool) {
|
|
let rects = self.spoilerRectsInContainer()
|
|
if rects.isEmpty || self.displayContentsUnderSpoilers {
|
|
if let dustNode = self.dustNode {
|
|
self.dustNode = nil
|
|
dustNode.view.removeFromSuperview()
|
|
}
|
|
return
|
|
}
|
|
|
|
let dustNode: InvisibleInkDustNode
|
|
if let current = self.dustNode {
|
|
dustNode = current
|
|
} else {
|
|
dustNode = InvisibleInkDustNode(textNode: nil, enableAnimations: self.enableSpoilerAnimations)
|
|
self.dustNode = dustNode
|
|
self.spoilerContainerView.addSubview(dustNode.view)
|
|
}
|
|
|
|
var color: UIColor = UIColor(white: 0.0, alpha: 1.0)
|
|
let textItem = self.item.textItem
|
|
if let firstRange = textItem.lines.first(where: { !$0.spoilerItems.isEmpty })?.spoilerItems.first?.range, firstRange.location < textItem.attributedString.length {
|
|
if let fg = textItem.attributedString.attribute(.foregroundColor, at: firstRange.location, effectiveRange: nil) as? UIColor {
|
|
color = fg
|
|
}
|
|
}
|
|
|
|
dustNode.view.frame = self.spoilerContainerView.bounds
|
|
dustNode.update(size: self.spoilerContainerView.bounds.size, color: color, textColor: color, rects: rects, wordRects: rects)
|
|
}
|
|
|
|
func setDisplayContentsUnderSpoilers(_ value: Bool, atLocation location: CGPoint?, animated: Bool) {
|
|
if self.displayContentsUnderSpoilers == value {
|
|
return
|
|
}
|
|
self.displayContentsUnderSpoilers = value
|
|
self.renderView.displayContentsUnderSpoilers = value
|
|
self.renderView.setNeedsDisplay()
|
|
if value {
|
|
if let dustNode = self.dustNode {
|
|
if let location, animated {
|
|
dustNode.revealAtLocation(self.convert(location, to: self.spoilerContainerView))
|
|
} else {
|
|
dustNode.update(revealed: true, animated: animated)
|
|
}
|
|
}
|
|
} else {
|
|
self.updateSpoiler(animated: animated)
|
|
}
|
|
}
|
|
|
|
func updateRevealCharacterCount(value: Int?, animated: Bool) {
|
|
if self.maxCharacterDrawCount == value {
|
|
return
|
|
}
|
|
self.maxCharacterDrawCount = value
|
|
self.updateRevealMask(animateNewSegments: animated)
|
|
}
|
|
|
|
var currentRevealCharacterCount: Int? {
|
|
return self.maxCharacterDrawCount
|
|
}
|
|
|
|
private struct RevealLineInfo {
|
|
let lineFrame: CGRect
|
|
let lineHeight: CGFloat
|
|
let revealedWidth: CGFloat
|
|
let isFull: Bool
|
|
let isRTL: Bool
|
|
}
|
|
|
|
private func computeRevealedLines(characterLimit: Int) -> [RevealLineInfo] {
|
|
var result: [RevealLineInfo] = []
|
|
var remainingCharacters = characterLimit
|
|
|
|
let textItem = self.item.textItem
|
|
let boundsWidth = textItem.frame.size.width
|
|
|
|
for line in textItem.lines {
|
|
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
|
|
// Translate from textItem-local to renderView-local coords (renderView's draw(_) translates by the inset).
|
|
let renderLocalLineFrame = lineFrame.offsetBy(dx: v2TextViewClippingInset, dy: v2TextViewClippingInset)
|
|
let lineHeight = lineFrame.size.height
|
|
|
|
guard let characterRects = line.characterRects else {
|
|
let revealedWidth: CGFloat = remainingCharacters > 0 ? renderLocalLineFrame.width : 0.0
|
|
result.append(RevealLineInfo(lineFrame: renderLocalLineFrame, lineHeight: lineHeight, revealedWidth: revealedWidth, isFull: remainingCharacters > 0, isRTL: line.isRTL))
|
|
if remainingCharacters > 0 {
|
|
remainingCharacters -= line.range.length
|
|
}
|
|
continue
|
|
}
|
|
|
|
if remainingCharacters <= 0 {
|
|
result.append(RevealLineInfo(lineFrame: renderLocalLineFrame, lineHeight: lineHeight, revealedWidth: 0.0, isFull: false, isRTL: line.isRTL))
|
|
continue
|
|
}
|
|
|
|
let revealCount = min(characterRects.count, remainingCharacters)
|
|
var revealedWidth: CGFloat = 0.0
|
|
if line.isRTL {
|
|
var minX: CGFloat = .greatestFiniteMagnitude
|
|
for j in 0 ..< revealCount {
|
|
let rect = characterRects[j]
|
|
if !rect.isEmpty {
|
|
minX = min(minX, rect.minX)
|
|
}
|
|
}
|
|
if minX != .greatestFiniteMagnitude {
|
|
revealedWidth = ceil(renderLocalLineFrame.width - minX)
|
|
}
|
|
} else {
|
|
for j in 0 ..< revealCount {
|
|
let rect = characterRects[j]
|
|
if !rect.isEmpty {
|
|
revealedWidth = max(revealedWidth, rect.maxX)
|
|
}
|
|
}
|
|
revealedWidth = ceil(revealedWidth)
|
|
}
|
|
|
|
remainingCharacters -= characterRects.count
|
|
let isFull = remainingCharacters >= 0
|
|
|
|
result.append(RevealLineInfo(lineFrame: renderLocalLineFrame, lineHeight: lineHeight, revealedWidth: revealedWidth, isFull: isFull, isRTL: line.isRTL))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private func updateRevealMask(animateNewSegments: Bool) {
|
|
let textItem = self.item.textItem
|
|
let lines = textItem.lines
|
|
let boundsWidth = textItem.frame.size.width
|
|
let layerSize = self.renderContainer.bounds.size
|
|
|
|
let effectiveCharacterDrawCount: Int
|
|
if let maxCharacterDrawCount = self.maxCharacterDrawCount {
|
|
effectiveCharacterDrawCount = maxCharacterDrawCount
|
|
} else {
|
|
if self.previousMaxCharacterDrawCount > 0 || !self.animatingSnippetLayers.isEmpty {
|
|
var totalCharCount = 0
|
|
for line in lines {
|
|
if let characterRects = line.characterRects {
|
|
totalCharCount += characterRects.count
|
|
} else {
|
|
totalCharCount += line.range.length
|
|
}
|
|
}
|
|
effectiveCharacterDrawCount = totalCharCount
|
|
} else {
|
|
if let _ = self.revealMaskLayer {
|
|
self.renderContainer.layer.mask = nil
|
|
self.revealMaskLayer = nil
|
|
self.revealLineMaskLayers.removeAll()
|
|
}
|
|
self.previousMaxCharacterDrawCount = 0
|
|
return
|
|
}
|
|
}
|
|
|
|
let revealMaskLayer: SimpleLayer
|
|
if let existing = self.revealMaskLayer {
|
|
revealMaskLayer = existing
|
|
} else {
|
|
revealMaskLayer = SimpleLayer()
|
|
revealMaskLayer.backgroundColor = UIColor.clear.cgColor
|
|
self.revealMaskLayer = revealMaskLayer
|
|
self.renderContainer.layer.mask = revealMaskLayer
|
|
}
|
|
revealMaskLayer.frame = CGRect(origin: .zero, size: layerSize)
|
|
|
|
let currentLineInfos = self.computeRevealedLines(characterLimit: effectiveCharacterDrawCount)
|
|
|
|
// Snippet spawn pass — animate newly-revealed characters.
|
|
if self.previousMaxCharacterDrawCount < effectiveCharacterDrawCount,
|
|
let contents = self.renderView.layer.contents,
|
|
animateNewSegments {
|
|
let containerOrigin = self.renderContainer.frame.origin
|
|
var previousRemaining = self.previousMaxCharacterDrawCount
|
|
var currentRemaining = effectiveCharacterDrawCount
|
|
var globalCharIndex = 0
|
|
|
|
for i in 0 ..< lines.count {
|
|
let line = lines[i]
|
|
let lineInfo = currentLineInfos[i]
|
|
guard let characterRects = line.characterRects else { continue }
|
|
|
|
let lineCharCount = characterRects.count
|
|
let prevCount = min(max(0, previousRemaining), lineCharCount)
|
|
let curCount = min(max(0, currentRemaining), lineCharCount)
|
|
|
|
previousRemaining -= lineCharCount
|
|
currentRemaining -= lineCharCount
|
|
|
|
if curCount <= prevCount {
|
|
globalCharIndex += lineCharCount
|
|
continue
|
|
}
|
|
|
|
for j in prevCount ..< curCount {
|
|
let charRect = characterRects[j]
|
|
if charRect.isEmpty { continue }
|
|
|
|
let snippetRect = CGRect(
|
|
x: lineInfo.lineFrame.minX + charRect.origin.x,
|
|
y: lineInfo.lineFrame.minY,
|
|
width: charRect.width,
|
|
height: lineInfo.lineHeight
|
|
)
|
|
|
|
if snippetRect.width < 0.5 { continue }
|
|
|
|
let contentsRect = CGRect(
|
|
x: snippetRect.minX / layerSize.width,
|
|
y: snippetRect.minY / layerSize.height,
|
|
width: snippetRect.width / layerSize.width,
|
|
height: snippetRect.height / layerSize.height
|
|
)
|
|
|
|
let snippetLayer = SnippetLayer(characterIndex: globalCharIndex + j)
|
|
snippetLayer.contents = contents
|
|
snippetLayer.contentsRect = contentsRect
|
|
snippetLayer.contentsScale = self.renderView.layer.contentsScale
|
|
snippetLayer.contentsGravity = self.renderView.layer.contentsGravity
|
|
snippetLayer.frame = snippetRect.offsetBy(dx: containerOrigin.x, dy: containerOrigin.y)
|
|
|
|
self.layer.addSublayer(snippetLayer)
|
|
self.animatingSnippetLayers.append(snippetLayer)
|
|
|
|
ComponentTransition(animation: .curve(duration: 0.22, curve: .easeInOut)).animateBlur(layer: snippetLayer, fromRadius: 2.0, toRadius: 0.0)
|
|
snippetLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
|
snippetLayer.animatePosition(from: CGPoint(x: 0.0, y: 6.0), to: .zero, duration: 0.2, additive: true)
|
|
snippetLayer.animateScale(from: 0.5, to: 1.0, duration: 0.2, completion: { [weak self, weak snippetLayer] _ in
|
|
guard let self, let snippetLayer else { return }
|
|
snippetLayer.removeFromSuperlayer()
|
|
self.animatingSnippetLayers.removeAll(where: { $0 === snippetLayer })
|
|
self.updateRevealMask(animateNewSegments: false)
|
|
})
|
|
}
|
|
globalCharIndex += lineCharCount
|
|
}
|
|
}
|
|
|
|
// Mask rebuild — when snippets are in flight, clamp to the lowest animating one
|
|
// (so the mask never exposes a char a snippet is still flying for). With no animations
|
|
// in flight, snap directly to the current target — `previousMaxCharacterDrawCount`
|
|
// would lag by one call (it's updated at the end of this function) and is 0 on a fresh
|
|
// view, which would hide every char until the next tick.
|
|
let maskCharacterLimit: Int
|
|
if let lowestAnimating = self.animatingSnippetLayers.min(by: { $0.characterIndex < $1.characterIndex })?.characterIndex {
|
|
maskCharacterLimit = lowestAnimating
|
|
} else {
|
|
maskCharacterLimit = effectiveCharacterDrawCount
|
|
}
|
|
// Build mask rects from each revealed glyph's ink bbox, unioned per line.
|
|
// Per-character rects are stored in line-local CT coords (y positive-up,
|
|
// baseline-relative; rect.minY is negative for descenders); convert to
|
|
// renderContainer-local UIKit coords as:
|
|
// x = renderLocalLineFrame.minX + rect.minX
|
|
// y = renderLocalLineFrame.minY + lineAscent - rect.maxY
|
|
// where `lineAscent = lineFrame.size.height` (top of line frame → baseline).
|
|
//
|
|
// Per-glyph rect captures descenders, italic overhang, accents exactly. Per
|
|
// line we accumulate the union of revealed glyphs into one mask rect (one
|
|
// CALayer sublayer per line), and consecutive fully-revealed lines collapse
|
|
// further into a single rect — so a fully-revealed prefix is always one
|
|
// sublayer regardless of line count.
|
|
//
|
|
// Lines without per-character data (computeRevealCharacterRects == false on
|
|
// a non-streaming layout) fall back to a line-spanning rect, treated as a
|
|
// full line for merging.
|
|
var maskRects: [CGRect] = []
|
|
var pendingFullPrefix: CGRect? = nil
|
|
var remainingChars = maskCharacterLimit
|
|
|
|
for line in lines {
|
|
if remainingChars <= 0 {
|
|
break
|
|
}
|
|
|
|
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
|
|
let renderLocalLineFrame = lineFrame.offsetBy(dx: v2TextViewClippingInset, dy: v2TextViewClippingInset)
|
|
let lineAscent = lineFrame.size.height
|
|
|
|
let lineUnion: CGRect?
|
|
let isFullLine: Bool
|
|
if let characterRects = line.characterRects {
|
|
let revealCount = min(characterRects.count, remainingChars)
|
|
isFullLine = revealCount >= characterRects.count
|
|
var union: CGRect? = nil
|
|
for j in 0 ..< revealCount {
|
|
let rect = characterRects[j]
|
|
if rect.isEmpty {
|
|
continue
|
|
}
|
|
let glyphRect = CGRect(
|
|
x: renderLocalLineFrame.minX + rect.minX,
|
|
y: renderLocalLineFrame.minY + lineAscent - rect.maxY,
|
|
width: rect.width,
|
|
height: rect.height
|
|
)
|
|
union = union?.union(glyphRect) ?? glyphRect
|
|
}
|
|
lineUnion = union
|
|
remainingChars -= characterRects.count
|
|
} else {
|
|
// No per-character data — expose the whole line.
|
|
lineUnion = line.range.length > 0 ? renderLocalLineFrame : nil
|
|
isFullLine = remainingChars >= line.range.length
|
|
remainingChars -= line.range.length
|
|
}
|
|
|
|
guard let lineUnion else {
|
|
continue
|
|
}
|
|
|
|
if isFullLine {
|
|
pendingFullPrefix = pendingFullPrefix?.union(lineUnion) ?? lineUnion
|
|
} else {
|
|
if let pending = pendingFullPrefix {
|
|
maskRects.append(pending)
|
|
pendingFullPrefix = nil
|
|
}
|
|
maskRects.append(lineUnion)
|
|
}
|
|
}
|
|
if let pending = pendingFullPrefix {
|
|
maskRects.append(pending)
|
|
}
|
|
|
|
while self.revealLineMaskLayers.count < maskRects.count {
|
|
let childLayer = SimpleLayer()
|
|
childLayer.backgroundColor = UIColor.white.cgColor
|
|
revealMaskLayer.addSublayer(childLayer)
|
|
self.revealLineMaskLayers.append(childLayer)
|
|
}
|
|
while self.revealLineMaskLayers.count > maskRects.count {
|
|
let removed = self.revealLineMaskLayers.removeLast()
|
|
removed.removeFromSuperlayer()
|
|
}
|
|
for i in 0 ..< maskRects.count {
|
|
self.revealLineMaskLayers[i].frame = maskRects[i]
|
|
}
|
|
|
|
self.previousMaxCharacterDrawCount = effectiveCharacterDrawCount
|
|
|
|
if self.maxCharacterDrawCount == nil && self.animatingSnippetLayers.isEmpty {
|
|
self.renderContainer.layer.mask = nil
|
|
self.revealMaskLayer = nil
|
|
self.revealLineMaskLayers.removeAll()
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class TextRenderView: UIView {
|
|
var item: InstantPageV2TextItem
|
|
var displayContentsUnderSpoilers: Bool = false
|
|
|
|
init(item: InstantPageV2TextItem) {
|
|
self.item = item
|
|
super.init(frame: .zero)
|
|
self.backgroundColor = .clear
|
|
self.isOpaque = false
|
|
self.contentMode = .redraw
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func draw(_ rect: CGRect) {
|
|
guard let context = UIGraphicsGetCurrentContext() else { return }
|
|
|
|
context.saveGState()
|
|
context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0)
|
|
context.translateBy(x: v2TextViewClippingInset, y: v2TextViewClippingInset)
|
|
|
|
if !self.displayContentsUnderSpoilers {
|
|
let textItemForClip = self.item.textItem
|
|
let clipBoundsWidth = textItemForClip.frame.size.width
|
|
var spoilerRects: [CGRect] = []
|
|
for line in textItemForClip.lines {
|
|
if line.spoilerItems.isEmpty { continue }
|
|
let lineFrame = v2FrameForLine(line, boundingWidth: clipBoundsWidth, alignment: textItemForClip.alignment)
|
|
for spoiler in line.spoilerItems {
|
|
spoilerRects.append(spoiler.frame.offsetBy(dx: lineFrame.minX - line.frame.minX, dy: 0.0))
|
|
}
|
|
}
|
|
if !spoilerRects.isEmpty {
|
|
// Even-odd subtracts each spoiler rect from the full-bounds rect. Spoiler rects never
|
|
// overlap (same-line ranges are horizontally monotonic; cross-line rects differ in y),
|
|
// so no hole is accidentally re-included by an odd crossing count.
|
|
let path = CGMutablePath()
|
|
path.addRect(CGRect(origin: .zero, size: textItemForClip.frame.size).insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset))
|
|
for rect in spoilerRects {
|
|
path.addRect(rect)
|
|
}
|
|
context.addPath(path)
|
|
context.clip(using: .evenOdd)
|
|
}
|
|
}
|
|
|
|
let textItem = self.item.textItem
|
|
let boundsWidth = textItem.frame.size.width
|
|
let intersectRect = rect.offsetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset)
|
|
|
|
for line in textItem.lines {
|
|
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
|
|
if !intersectRect.intersects(lineFrame) {
|
|
continue
|
|
}
|
|
|
|
let lineOrigin = lineFrame.origin
|
|
context.textPosition = CGPoint(x: lineOrigin.x, y: lineOrigin.y + lineFrame.size.height)
|
|
|
|
if !line.markedItems.isEmpty {
|
|
context.saveGState()
|
|
for item in line.markedItems {
|
|
let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0)
|
|
context.setFillColor(item.color.cgColor)
|
|
|
|
let height = floor(item.frame.size.height * 2.2)
|
|
let markRect = CGRect(x: itemFrame.minX - 2.0, y: floor(itemFrame.minY + (itemFrame.height - height) / 2.0), width: itemFrame.width + 4.0, height: height)
|
|
let path = UIBezierPath(roundedRect: markRect, cornerRadius: 3.0)
|
|
context.addPath(path.cgPath)
|
|
context.fillPath()
|
|
}
|
|
context.restoreGState()
|
|
}
|
|
|
|
if textItem.opaqueBackground {
|
|
context.setBlendMode(.normal)
|
|
}
|
|
|
|
let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray
|
|
if glyphRuns.count != 0 {
|
|
for run in glyphRuns {
|
|
let run = run as! CTRun
|
|
let glyphCount = CTRunGetGlyphCount(run)
|
|
CTRunDraw(run, context, CFRangeMake(0, glyphCount))
|
|
}
|
|
}
|
|
|
|
if textItem.opaqueBackground {
|
|
context.setBlendMode(.copy)
|
|
}
|
|
|
|
if !line.strikethroughItems.isEmpty {
|
|
for item in line.strikethroughItems {
|
|
let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0)
|
|
context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + floor((itemFrame.size.height / 2.0) + 1.0), width: itemFrame.size.width, height: 1.0))
|
|
}
|
|
}
|
|
|
|
if !line.underlineItems.isEmpty {
|
|
for item in line.underlineItems {
|
|
var color: UIColor? = item.color
|
|
if color == nil {
|
|
textItem.attributedString.enumerateAttributes(in: item.range, options: []) { attributes, _, _ in
|
|
if let foreground = attributes[NSAttributedString.Key.foregroundColor] as? UIColor {
|
|
color = foreground
|
|
}
|
|
}
|
|
}
|
|
if let color {
|
|
context.setFillColor(color.cgColor)
|
|
}
|
|
let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0)
|
|
context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + itemFrame.size.height + 2.0, width: itemFrame.size.width, height: 1.0))
|
|
}
|
|
}
|
|
}
|
|
|
|
context.restoreGState()
|
|
}
|
|
}
|
|
|
|
/// Private snippet layer for the streaming reveal pop-in animation.
|
|
/// Each instance represents one character cropped from the renderView's backing texture,
|
|
/// animating in (blur + alpha + position + scale) before the mask absorbs its rect.
|
|
private final class SnippetLayer: SimpleLayer {
|
|
let characterIndex: Int
|
|
|
|
init(characterIndex: Int) {
|
|
self.characterIndex = characterIndex
|
|
super.init()
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override init(layer: Any) {
|
|
if let other = layer as? SnippetLayer {
|
|
self.characterIndex = other.characterIndex
|
|
} else {
|
|
self.characterIndex = 0
|
|
}
|
|
super.init(layer: layer)
|
|
}
|
|
}
|
|
|
|
// MARK: - Divider view
|
|
|
|
final class InstantPageV2DividerView: UIView, InstantPageItemView {
|
|
private(set) var item: InstantPageV2DividerItem
|
|
var itemFrame: CGRect { return self.item.frame }
|
|
|
|
init(item: InstantPageV2DividerItem, theme: InstantPageTheme) {
|
|
self.item = item
|
|
super.init(frame: item.frame)
|
|
self.update(item: item, theme: theme)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
func update(item: InstantPageV2DividerItem, theme: InstantPageTheme) {
|
|
let _ = theme
|
|
self.item = item
|
|
self.backgroundColor = item.color
|
|
}
|
|
}
|
|
|
|
// MARK: - Anchor view (zero-height; nothing to render)
|
|
|
|
final class InstantPageV2AnchorView: UIView, InstantPageItemView {
|
|
private(set) var item: InstantPageV2AnchorItem
|
|
var itemFrame: CGRect { return self.item.frame }
|
|
|
|
init(item: InstantPageV2AnchorItem, theme: InstantPageTheme) {
|
|
self.item = item
|
|
super.init(frame: item.frame)
|
|
self.isHidden = true // structural: zero-height, never renders
|
|
self.update(item: item, theme: theme)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
func update(item: InstantPageV2AnchorItem, theme: InstantPageTheme) {
|
|
let _ = theme
|
|
self.item = item
|
|
}
|
|
}
|
|
|
|
// MARK: - List marker view
|
|
|
|
final class InstantPageV2ListMarkerView: UIView, InstantPageItemView {
|
|
private(set) var item: InstantPageV2ListMarkerItem
|
|
var itemFrame: CGRect { return self.item.frame }
|
|
|
|
init(item: InstantPageV2ListMarkerItem, theme: InstantPageTheme) {
|
|
self.item = item
|
|
super.init(frame: item.frame)
|
|
self.backgroundColor = .clear // structural
|
|
self.isOpaque = false // structural
|
|
self.update(item: item, theme: theme)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
func update(item: InstantPageV2ListMarkerItem, theme: InstantPageTheme) {
|
|
let _ = theme
|
|
self.item = item
|
|
self.rebuildContents()
|
|
}
|
|
|
|
private func rebuildContents() {
|
|
for subview in Array(self.subviews) {
|
|
subview.removeFromSuperview()
|
|
}
|
|
if let sublayers = self.layer.sublayers {
|
|
for sublayer in Array(sublayers) {
|
|
sublayer.removeFromSuperlayer()
|
|
}
|
|
}
|
|
|
|
let item = self.item
|
|
switch item.kind {
|
|
case .bullet:
|
|
let radius: CGFloat = min(item.frame.width, item.frame.height) / 2.0
|
|
let dot = CALayer()
|
|
dot.backgroundColor = item.color.cgColor
|
|
dot.frame = CGRect(
|
|
x: (item.frame.width - radius * 2.0) / 2.0,
|
|
y: (item.frame.height - radius * 2.0) / 2.0,
|
|
width: radius * 2.0,
|
|
height: radius * 2.0
|
|
)
|
|
dot.cornerRadius = radius
|
|
self.layer.addSublayer(dot)
|
|
case let .number(text):
|
|
let label = UILabel()
|
|
label.text = text
|
|
label.textColor = item.color
|
|
label.font = UIFont.systemFont(ofSize: 17.0)
|
|
label.textAlignment = .right
|
|
label.frame = CGRect(origin: .zero, size: item.frame.size)
|
|
self.addSubview(label)
|
|
case let .checklist(checked, colors):
|
|
let checkNodeTheme = CheckNodeTheme(
|
|
backgroundColor: colors.background,
|
|
strokeColor: colors.stroke,
|
|
borderColor: colors.border,
|
|
overlayBorder: false,
|
|
hasInset: false,
|
|
hasShadow: false
|
|
)
|
|
let checkNode = CheckNode(theme: checkNodeTheme, content: .check(isRectangle: true))
|
|
checkNode.displaysAsynchronously = false
|
|
checkNode.isUserInteractionEnabled = false
|
|
checkNode.frame = CGRect(origin: .zero, size: item.frame.size)
|
|
checkNode.setSelected(checked, animated: false)
|
|
self.addSubview(checkNode.view)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Quote bar view
|
|
|
|
final class InstantPageV2BlockQuoteBarView: UIView, InstantPageItemView {
|
|
private(set) var item: InstantPageV2BarItem
|
|
var itemFrame: CGRect { return self.item.frame }
|
|
|
|
init(item: InstantPageV2BarItem, theme: InstantPageTheme) {
|
|
self.item = item
|
|
super.init(frame: item.frame)
|
|
self.update(item: item, theme: theme)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
func update(item: InstantPageV2BarItem, theme: InstantPageTheme) {
|
|
let _ = theme
|
|
self.item = item
|
|
self.backgroundColor = item.color
|
|
self.layer.cornerRadius = item.cornerRadius
|
|
}
|
|
}
|
|
|
|
// MARK: - Shape view (for pullQuote line ornaments)
|
|
|
|
final class InstantPageV2ShapeView: UIView, InstantPageItemView {
|
|
private(set) var item: InstantPageV2ShapeItem
|
|
var itemFrame: CGRect { return self.item.frame }
|
|
|
|
init(item: InstantPageV2ShapeItem, theme: InstantPageTheme) {
|
|
self.item = item
|
|
super.init(frame: item.frame)
|
|
self.update(item: item, theme: theme)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
func update(item: InstantPageV2ShapeItem, theme: InstantPageTheme) {
|
|
let _ = theme
|
|
self.item = item
|
|
self.applyKind()
|
|
}
|
|
|
|
private func applyKind() {
|
|
switch self.item.kind {
|
|
case let .roundedRect(cornerRadius):
|
|
self.backgroundColor = self.item.color
|
|
self.layer.cornerRadius = cornerRadius
|
|
case .line(_):
|
|
self.backgroundColor = self.item.color
|
|
self.layer.cornerRadius = 0.0
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Media placeholder view (V0: gray rectangle)
|
|
|
|
final class InstantPageV2MediaPlaceholderView: UIView, InstantPageItemView {
|
|
private(set) var item: InstantPageV2MediaPlaceholderItem
|
|
var itemFrame: CGRect { return self.item.frame }
|
|
|
|
init(item: InstantPageV2MediaPlaceholderItem, theme: InstantPageTheme) {
|
|
self.item = item
|
|
super.init(frame: item.frame)
|
|
self.update(item: item, theme: theme)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
func update(item: InstantPageV2MediaPlaceholderItem, theme: InstantPageTheme) {
|
|
self.item = item
|
|
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
|
|
}
|
|
}
|
|
|
|
// MARK: - Details view
|
|
|
|
final class InstantPageV2DetailsView: UIView, InstantPageItemView {
|
|
private(set) var item: InstantPageV2DetailsItem
|
|
var itemFrame: CGRect { return self.item.frame }
|
|
|
|
let titleTextView: InstantPageV2TextView
|
|
private let chevronView: UIImageView
|
|
private let separator: UIView
|
|
var bodyView: InstantPageV2View?
|
|
private let titleHitView: UIView
|
|
|
|
// The expanded chevron is the collapsed one rotated 180° (down → up).
|
|
private static let expandedChevronTransform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0)
|
|
// On an animated collapse the body is kept until the toggle animation finishes (so the
|
|
// shrinking clip can hide it), then removed in finalizePendingCollapse() — which the parent
|
|
// (InstantPageV2View.update) calls from the completion of the frame-shrink (clip) animation.
|
|
private var bodyPendingRemoval = false
|
|
|
|
var onTitleTapped: ((Int) -> Void)?
|
|
|
|
var subLayoutView: InstantPageV2View? { return self.bodyView }
|
|
|
|
func forEachSubLayoutView(_ body: (InstantPageV2View) -> Void) {
|
|
if let bodyView = self.bodyView { body(bodyView) }
|
|
}
|
|
|
|
init(item: InstantPageV2DetailsItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext?) {
|
|
self.item = item
|
|
|
|
let titleV2Item = InstantPageV2TextItem(
|
|
frame: item.titleTextItem.frame,
|
|
textItem: item.titleTextItem
|
|
)
|
|
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.contentMode = .scaleAspectFit
|
|
// Decorative: let taps fall through to titleHitView (which carries the toggle gesture).
|
|
self.chevronView.isUserInteractionEnabled = false
|
|
|
|
self.separator = UIView()
|
|
self.separator.isUserInteractionEnabled = false
|
|
|
|
self.titleHitView = UIView()
|
|
self.titleHitView.backgroundColor = .clear
|
|
|
|
super.init(frame: item.frame)
|
|
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)
|
|
|
|
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)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
@objc private func titleTapped() {
|
|
self.onTitleTapped?(self.item.index)
|
|
}
|
|
|
|
func update(item: InstantPageV2DetailsItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext?, animation: ListViewItemUpdateAnimation) {
|
|
self.item = item
|
|
|
|
let titleV2Item = InstantPageV2TextItem(
|
|
frame: item.titleTextItem.frame,
|
|
textItem: item.titleTextItem
|
|
)
|
|
self.titleTextView.update(item: titleV2Item, theme: theme)
|
|
|
|
self.chevronView.tintColor = theme.secondaryControlColor
|
|
let chevronSize = CGSize(width: 18.0, height: 18.0)
|
|
self.chevronView.bounds = CGRect(origin: .zero, size: chevronSize)
|
|
self.chevronView.center = CGPoint(
|
|
x: item.sideInset + chevronSize.width / 2.0,
|
|
y: item.titleFrame.midY + 1.0
|
|
)
|
|
|
|
self.titleHitView.frame = item.titleFrame
|
|
|
|
// Body lifecycle. The reveal/hide of the body is produced by the parent animating this
|
|
// view's own frame height (clipsToBounds = true), not by the body itself — see
|
|
// InstantPageV2View.update. The body's internal layout is forwarded `animation` so a
|
|
// *nested* details block inside the body can also animate its own toggle.
|
|
let blockHeight: CGFloat
|
|
if item.isExpanded {
|
|
if let innerLayout = item.innerLayout {
|
|
let body: InstantPageV2View
|
|
if let existingBody = self.bodyView {
|
|
// Reuse: covers expanded→expanded content updates and a re-expand that
|
|
// interrupts a still-pending collapse removal.
|
|
body = existingBody
|
|
self.bodyPendingRemoval = false
|
|
} else {
|
|
body = InstantPageV2View(renderContext: renderContext)
|
|
self.addSubview(body)
|
|
self.bodyView = body
|
|
}
|
|
body.update(layout: innerLayout, theme: theme, animation: animation)
|
|
body.frame = CGRect(
|
|
origin: CGPoint(x: 0.0, y: item.titleFrame.maxY),
|
|
size: innerLayout.contentSize
|
|
)
|
|
blockHeight = body.frame.maxY
|
|
} else {
|
|
blockHeight = item.titleFrame.maxY
|
|
}
|
|
} else {
|
|
if let existingBody = self.bodyView {
|
|
if animation.isAnimated {
|
|
// Keep the body visible while the parent's frame-shrink clips it away;
|
|
// it is removed in finalizePendingCollapse() (called from that clip animation's
|
|
// completion in InstantPageV2View.update).
|
|
self.bodyPendingRemoval = true
|
|
} else {
|
|
existingBody.removeFromSuperview()
|
|
self.bodyView = nil
|
|
}
|
|
}
|
|
blockHeight = item.titleFrame.maxY
|
|
}
|
|
|
|
self.separator.backgroundColor = item.separatorColor
|
|
animation.animator.updateFrame(layer: self.separator.layer, frame: CGRect(
|
|
x: 8.0,
|
|
y: blockHeight - UIScreenPixel,
|
|
width: item.frame.width - 8.0 * 2.0,
|
|
height: UIScreenPixel
|
|
), completion: nil)
|
|
|
|
// Chevron rotation. The body teardown on collapse is NOT tied to this completion — see
|
|
// finalizePendingCollapse(), which the parent calls from the frame-shrink (clip) animation.
|
|
let targetTransform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity
|
|
animation.animator.updateTransform(layer: self.chevronView.layer, transform: targetTransform, completion: nil)
|
|
}
|
|
|
|
/// Removes the body kept alive across an animated collapse. The parent (InstantPageV2View.update)
|
|
/// calls this from the completion of the frame-shrink animation that clips the body away, so the
|
|
/// body is torn down exactly when it finishes being hidden. The guard makes a re-expand that
|
|
/// interrupts the collapse safe — the re-expand clears `bodyPendingRemoval` first.
|
|
func finalizePendingCollapse() {
|
|
if !self.item.isExpanded, self.bodyPendingRemoval {
|
|
self.bodyView?.removeFromSuperview()
|
|
self.bodyView = nil
|
|
self.bodyPendingRemoval = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Code block view
|
|
|
|
final class InstantPageV2CodeBlockView: UIView, InstantPageItemView {
|
|
private(set) var item: InstantPageV2CodeBlockItem
|
|
var itemFrame: CGRect { return self.item.frame }
|
|
|
|
private let backgroundLayer: CALayer
|
|
let textView: InstantPageV2TextView
|
|
|
|
init(item: InstantPageV2CodeBlockItem, theme: InstantPageTheme) {
|
|
self.item = item
|
|
|
|
self.backgroundLayer = CALayer()
|
|
|
|
// 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, theme: theme)
|
|
|
|
super.init(frame: item.frame)
|
|
self.backgroundColor = .clear // structural
|
|
self.layer.addSublayer(self.backgroundLayer) // structural
|
|
self.addSubview(self.textView) // structural
|
|
self.update(item: item, theme: theme)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
func update(item: InstantPageV2CodeBlockItem, theme: InstantPageTheme) {
|
|
self.item = item
|
|
self.backgroundLayer.backgroundColor = item.backgroundColor.cgColor
|
|
self.backgroundLayer.cornerRadius = item.cornerRadius
|
|
self.backgroundLayer.frame = CGRect(origin: .zero, size: item.frame.size)
|
|
|
|
let innerV2TextItem = InstantPageV2TextItem(
|
|
frame: item.textItem.frame,
|
|
textItem: item.textItem
|
|
)
|
|
self.textView.update(item: innerV2TextItem, theme: theme)
|
|
}
|
|
}
|
|
|
|
// MARK: - Thinking view (dimmed shimmering reasoning block)
|
|
|
|
/// A top-level thinking block: dimmed text drawn fully, masked by a continuously-running
|
|
/// `ShimmeringMaskView`. Reveal is whole-block alpha (driven from the cost map), NOT char-by-char,
|
|
/// and the block contributes zero reveal cost. Structure mirrors `InstantPageV2CodeBlockView`
|
|
/// (container hosting an inner `InstantPageV2TextView`).
|
|
final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
|
|
private(set) var item: InstantPageV2ThinkingItem
|
|
var itemFrame: CGRect { return self.item.frame }
|
|
|
|
private let shimmerView: ShimmeringMaskView
|
|
let textView: InstantPageV2TextView // exposed so the parent V2 view can host its inline emoji
|
|
|
|
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, theme: theme)
|
|
|
|
super.init(frame: item.frame)
|
|
self.backgroundColor = .clear // structural
|
|
self.addSubview(self.shimmerView) // structural
|
|
self.shimmerView.contentView.addSubview(self.textView) // structural
|
|
self.update(item: item, theme: theme)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
/// Parent positions self at the item frame (the bare line box). The shimmer and its gradient
|
|
/// mask are sized to the text view's clipping-inset-EXPANDED frame and shifted to
|
|
/// `(-inset, -inset)`, so the mask doesn't crop the glyph overhang the inset reserves (tall
|
|
/// ascenders, descenders, the last line's underline) — the symptom of sizing the mask to the
|
|
/// bare line box. The inner text view fills the shimmer; its `+inset` render translate lands the
|
|
/// glyphs back at self's origin, so the text position is unchanged. Mirrors how a `.text` view's
|
|
/// frame is inset-expanded (`actualFrame` / `InstantPageV2TextView.init`).
|
|
private func layoutContents() {
|
|
let inset = v2TextViewClippingInset
|
|
let expandedSize = CGSize(width: self.item.frame.size.width + inset * 2.0,
|
|
height: self.item.frame.size.height + inset * 2.0)
|
|
self.shimmerView.frame = CGRect(x: -inset, y: -inset, width: expandedSize.width, height: expandedSize.height)
|
|
self.textView.frame = CGRect(origin: .zero, size: expandedSize)
|
|
self.shimmerView.update(
|
|
size: expandedSize,
|
|
containerWidth: expandedSize.width,
|
|
offsetX: 0.0,
|
|
gradientWidth: 200.0,
|
|
transition: .immediate
|
|
)
|
|
}
|
|
|
|
func update(item: InstantPageV2ThinkingItem, theme: InstantPageTheme) {
|
|
self.item = item
|
|
let innerV2TextItem = InstantPageV2TextItem(frame: item.textItem.frame, textItem: item.textItem)
|
|
self.textView.update(item: innerV2TextItem, theme: theme)
|
|
self.layoutContents()
|
|
}
|
|
}
|
|
|
|
// MARK: - Table view
|
|
|
|
/// The set of grid corners a cell occupies, so a filled corner cell's stripe can be rounded to
|
|
/// follow the table's rounded outer border. `cellFrame` is table-grid-local (pre-`gridOffsetY`).
|
|
private func tableStripeCornerMask(cellFrame: CGRect, gridWidth: CGFloat, gridHeight: CGFloat, effectiveBorderWidth: CGFloat) -> CACornerMask {
|
|
let edge = effectiveBorderWidth / 2.0 + 0.5
|
|
let firstCol = cellFrame.minX <= edge
|
|
let firstRow = cellFrame.minY <= edge
|
|
let lastCol = cellFrame.maxX >= gridWidth - edge
|
|
let lastRow = cellFrame.maxY >= gridHeight - edge
|
|
var mask: CACornerMask = []
|
|
if firstRow && firstCol { mask.insert(.layerMinXMinYCorner) }
|
|
if firstRow && lastCol { mask.insert(.layerMaxXMinYCorner) }
|
|
if lastRow && firstCol { mask.insert(.layerMinXMaxYCorner) }
|
|
if lastRow && lastCol { mask.insert(.layerMaxXMaxYCorner) }
|
|
return mask
|
|
}
|
|
|
|
final class InstantPageV2TableView: UIView, InstantPageItemView {
|
|
private(set) var item: InstantPageV2TableItem
|
|
var itemFrame: CGRect { return self.item.frame }
|
|
|
|
private let scrollView: UIScrollView
|
|
let contentView: UIView
|
|
var titleSubView: InstantPageV2View?
|
|
var cellSubViews: [InstantPageV2View] = []
|
|
private var stripeLayers: [CALayer] = []
|
|
private var lineLayers: [CALayer] = []
|
|
|
|
var subLayoutView: InstantPageV2View? { return nil }
|
|
|
|
func forEachSubLayoutView(_ body: (InstantPageV2View) -> Void) {
|
|
if let titleView = self.titleSubView { body(titleView) }
|
|
for cellView in self.cellSubViews { body(cellView) }
|
|
}
|
|
|
|
init(item: InstantPageV2TableItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext?) {
|
|
self.item = item
|
|
self.scrollView = UIScrollView()
|
|
self.contentView = UIView()
|
|
super.init(frame: item.frame)
|
|
self.backgroundColor = .clear
|
|
|
|
// 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 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.alwaysBounceHorizontal = false
|
|
self.scrollView.alwaysBounceVertical = false
|
|
self.scrollView.showsVerticalScrollIndicator = false
|
|
self.scrollView.disablesInteractiveTransitionGestureRecognizer = true
|
|
self.addSubview(self.scrollView)
|
|
self.scrollView.addSubview(self.contentView)
|
|
|
|
// 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)
|
|
self.contentView.addSubview(v)
|
|
self.titleSubView = v
|
|
}
|
|
for cell in item.cells {
|
|
if cell.backgroundColor != nil {
|
|
let stripe = CALayer()
|
|
self.contentView.layer.insertSublayer(stripe, at: 0)
|
|
self.stripeLayers.append(stripe)
|
|
}
|
|
if cell.subLayout != nil {
|
|
let v = InstantPageV2View(renderContext: renderContext)
|
|
self.contentView.addSubview(v)
|
|
self.cellSubViews.append(v)
|
|
}
|
|
}
|
|
if item.bordered {
|
|
for _ in item.horizontalLines + item.verticalLines {
|
|
let line = CALayer()
|
|
self.contentView.layer.addSublayer(line)
|
|
self.lineLayers.append(line)
|
|
}
|
|
}
|
|
|
|
self.update(item: item, theme: theme)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
func update(item: InstantPageV2TableItem, theme: InstantPageTheme) {
|
|
self.item = item
|
|
|
|
self.scrollView.frame = CGRect(origin: .zero, size: item.frame.size)
|
|
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
|
|
// propagation. Cell-count or title-presence changes fall back to rebuild via the
|
|
// V2View's internal `update(layout:theme:animation:)` (task B refines).
|
|
if let titleLayout = item.titleSubLayout, let titleView = self.titleSubView, let titleFrame = item.titleFrame {
|
|
titleView.update(layout: titleLayout, theme: theme, animation: .None)
|
|
titleView.frame = CGRect(
|
|
x: v2TableCellInsets.left,
|
|
y: titleFrame.minY + v2TableCellInsets.top,
|
|
width: titleLayout.contentSize.width,
|
|
height: titleLayout.contentSize.height
|
|
)
|
|
}
|
|
|
|
let gridOffsetY = item.titleFrame?.height ?? 0.0
|
|
var cellLayoutIndex = 0
|
|
for cell in item.cells {
|
|
if let subLayout = cell.subLayout, cellLayoutIndex < self.cellSubViews.count {
|
|
let cellView = self.cellSubViews[cellLayoutIndex]
|
|
cellView.update(layout: subLayout, theme: theme, animation: .None)
|
|
cellView.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
|
|
cellLayoutIndex += 1
|
|
}
|
|
}
|
|
|
|
// Stripe layers (cell backgrounds) — update color + frame + corner rounding in original order.
|
|
let effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0
|
|
let gridHeight = item.contentSize.height - gridOffsetY
|
|
var stripeIndex = 0
|
|
for cell in item.cells {
|
|
if let bg = cell.backgroundColor, stripeIndex < self.stripeLayers.count {
|
|
let stripe = self.stripeLayers[stripeIndex]
|
|
stripe.backgroundColor = bg.cgColor
|
|
stripe.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
|
|
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
|
|
}
|
|
stripeIndex += 1
|
|
}
|
|
}
|
|
|
|
// 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.)
|
|
// 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).
|
|
self.contentView.layer.cornerRadius = v2TableCornerRadius
|
|
self.contentView.layer.borderColor = item.borderColor.cgColor
|
|
self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0
|
|
}
|
|
}
|
|
|
|
// MARK: - Public helpers on InstantPageV2View
|
|
|
|
public extension InstantPageV2View {
|
|
func lastTextLineFrame() -> CGRect? {
|
|
guard let layout = self.currentLayout else { return nil }
|
|
return InstantPageUI.lastTextLineFrame(in: layout)
|
|
}
|
|
|
|
func textItemAt(point: CGPoint) -> (item: InstantPageTextItem, parentOffset: CGPoint)? {
|
|
guard let layout = self.currentLayout else { return nil }
|
|
return findTextItem(in: layout, point: point, accumulatedOffset: .zero)
|
|
}
|
|
|
|
func urlItemAt(point: CGPoint) -> (urlItem: InstantPageUrlItem, item: InstantPageTextItem,
|
|
parentOffset: CGPoint, localPoint: CGPoint)? {
|
|
guard let hit = self.textItemAt(point: point) else { return nil }
|
|
let localPoint = CGPoint(x: point.x - hit.parentOffset.x, y: point.y - hit.parentOffset.y)
|
|
guard let url = hit.item.urlAttribute(at: localPoint) else { return nil }
|
|
return (urlItem: url, item: hit.item, parentOffset: hit.parentOffset, localPoint: localPoint)
|
|
}
|
|
|
|
func selectableTextItems() -> [(item: InstantPageTextItem, parentOffset: CGPoint)] {
|
|
guard let layout = self.currentLayout else { return [] }
|
|
var result: [(InstantPageTextItem, CGPoint)] = []
|
|
collectSelectableTextItems(in: layout, accumulatedOffset: .zero, into: &result)
|
|
return result.map { (item: $0.0, parentOffset: $0.1) }
|
|
}
|
|
|
|
func detailsItem(atIndex index: Int) -> (frame: CGRect, titleFrame: CGRect)? {
|
|
guard let layout = self.currentLayout else { return nil }
|
|
for item in layout.items {
|
|
if case let .details(d) = item, d.index == index {
|
|
return (frame: d.frame, titleFrame: d.titleFrame.offsetBy(dx: d.frame.minX, dy: d.frame.minY))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Private recursion helpers
|
|
|
|
private func findTextItem(
|
|
in layout: InstantPageV2Layout,
|
|
point: CGPoint,
|
|
accumulatedOffset: CGPoint
|
|
) -> (item: InstantPageTextItem, parentOffset: CGPoint)? {
|
|
for item in layout.items {
|
|
let f = item.frame.offsetBy(dx: accumulatedOffset.x, dy: accumulatedOffset.y)
|
|
if !f.contains(point) { continue }
|
|
switch item {
|
|
case let .text(text):
|
|
return (item: text.textItem, parentOffset: CGPoint(x: f.minX, y: f.minY))
|
|
case let .codeBlock(block):
|
|
let textOrigin = CGPoint(
|
|
x: f.minX + block.textItem.frame.minX,
|
|
y: f.minY + block.textItem.frame.minY
|
|
)
|
|
return (item: block.textItem, parentOffset: textOrigin)
|
|
case let .details(details):
|
|
if details.titleFrame.offsetBy(dx: f.minX, dy: f.minY).contains(point) {
|
|
let titleOrigin = CGPoint(
|
|
x: f.minX + details.titleTextItem.frame.minX,
|
|
y: f.minY + details.titleTextItem.frame.minY
|
|
)
|
|
return (item: details.titleTextItem, parentOffset: titleOrigin)
|
|
}
|
|
if let inner = details.innerLayout {
|
|
let innerOffset = CGPoint(x: f.minX, y: f.minY + details.titleFrame.maxY)
|
|
if let hit = findTextItem(in: inner, point: point, accumulatedOffset: innerOffset) {
|
|
return hit
|
|
}
|
|
}
|
|
case let .table(table):
|
|
for cell in table.cells {
|
|
let cellAbs = cell.frame.offsetBy(dx: f.minX + table.contentInset, dy: f.minY)
|
|
if !cellAbs.contains(point) { continue }
|
|
if let sub = cell.subLayout {
|
|
if let hit = findTextItem(in: sub, point: point,
|
|
accumulatedOffset: CGPoint(x: cellAbs.minX, y: cellAbs.minY)) {
|
|
return hit
|
|
}
|
|
}
|
|
}
|
|
if let titleLayout = table.titleSubLayout, let titleFrame = table.titleFrame {
|
|
let titleAbs = titleFrame.offsetBy(dx: f.minX + table.contentInset, dy: f.minY)
|
|
if titleAbs.contains(point) {
|
|
if let hit = findTextItem(in: titleLayout, point: point,
|
|
accumulatedOffset: CGPoint(x: titleAbs.minX, y: titleAbs.minY)) {
|
|
return hit
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func collectSelectableTextItems(
|
|
in layout: InstantPageV2Layout,
|
|
accumulatedOffset: CGPoint,
|
|
into result: inout [(InstantPageTextItem, CGPoint)]
|
|
) {
|
|
for item in layout.items {
|
|
switch item {
|
|
case let .text(text):
|
|
if text.textItem.selectable && !text.textItem.attributedString.string.isEmpty {
|
|
result.append((text.textItem, CGPoint(
|
|
x: accumulatedOffset.x + text.frame.minX,
|
|
y: accumulatedOffset.y + text.frame.minY
|
|
)))
|
|
}
|
|
case let .codeBlock(block):
|
|
if block.textItem.selectable && !block.textItem.attributedString.string.isEmpty {
|
|
result.append((block.textItem, CGPoint(
|
|
x: accumulatedOffset.x + block.frame.minX + block.textItem.frame.minX,
|
|
y: accumulatedOffset.y + block.frame.minY + block.textItem.frame.minY
|
|
)))
|
|
}
|
|
case let .details(details):
|
|
if details.titleTextItem.selectable && !details.titleTextItem.attributedString.string.isEmpty {
|
|
result.append((details.titleTextItem, CGPoint(
|
|
x: accumulatedOffset.x + details.frame.minX + details.titleTextItem.frame.minX,
|
|
y: accumulatedOffset.y + details.frame.minY + details.titleTextItem.frame.minY
|
|
)))
|
|
}
|
|
if let inner = details.innerLayout {
|
|
let innerOffset = CGPoint(
|
|
x: accumulatedOffset.x + details.frame.minX,
|
|
y: accumulatedOffset.y + details.frame.minY + details.titleFrame.maxY
|
|
)
|
|
collectSelectableTextItems(in: inner, accumulatedOffset: innerOffset, into: &result)
|
|
}
|
|
case let .table(table):
|
|
if let titleLayout = table.titleSubLayout, let titleFrame = table.titleFrame {
|
|
let titleOffset = CGPoint(
|
|
x: accumulatedOffset.x + table.frame.minX + table.contentInset + titleFrame.minX,
|
|
y: accumulatedOffset.y + table.frame.minY + titleFrame.minY
|
|
)
|
|
collectSelectableTextItems(in: titleLayout, accumulatedOffset: titleOffset, into: &result)
|
|
}
|
|
for cell in table.cells {
|
|
if let sub = cell.subLayout {
|
|
let cellOffset = CGPoint(
|
|
x: accumulatedOffset.x + table.frame.minX + table.contentInset + cell.frame.minX,
|
|
y: accumulatedOffset.y + table.frame.minY + cell.frame.minY
|
|
)
|
|
collectSelectableTextItems(in: sub, accumulatedOffset: cellOffset, into: &result)
|
|
}
|
|
}
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Formula view
|
|
|
|
/// Renders both block (`InstantPageBlock.formula(latex:)`) and inline (`InstantPageFormulaAttribute`)
|
|
/// math, sourcing the pre-rendered Retina image from `InstantPageMathAttachment.rendered`.
|
|
///
|
|
/// For block formulas wider than the bubble's available width, the layout sets
|
|
/// `isScrollable = true`; this view then wraps the image in a horizontal `UIScrollView`
|
|
/// matching V1's `InstantPageScrollableNode` (no bounce on non-overflowing content,
|
|
/// scroll indicator hidden — appropriate for content embedded inside a chat bubble).
|
|
final class InstantPageV2FormulaView: UIView, InstantPageItemView {
|
|
private(set) var item: InstantPageV2FormulaItem
|
|
var itemFrame: CGRect { return self.item.frame }
|
|
|
|
init(item: InstantPageV2FormulaItem, theme: InstantPageTheme) {
|
|
self.item = item
|
|
super.init(frame: item.frame)
|
|
self.backgroundColor = .clear // structural
|
|
self.isOpaque = false // structural
|
|
self.update(item: item, theme: theme)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
func update(item: InstantPageV2FormulaItem, theme: InstantPageTheme) {
|
|
let _ = theme
|
|
self.item = item
|
|
|
|
// 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() }
|
|
}
|
|
self.buildContents()
|
|
}
|
|
|
|
private func buildContents() {
|
|
let item = self.item
|
|
let imageLayer = CALayer()
|
|
imageLayer.contents = item.attachment.rendered.image.cgImage
|
|
imageLayer.contentsScale = item.attachment.rendered.image.scale
|
|
imageLayer.contentsGravity = .resizeAspect
|
|
imageLayer.frame = item.imageFrame
|
|
|
|
if item.isScrollable {
|
|
self.clipsToBounds = true
|
|
self.isUserInteractionEnabled = true
|
|
let scroll = UIScrollView(frame: CGRect(origin: .zero, size: item.frame.size))
|
|
scroll.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
scroll.contentSize = item.scrollContentSize
|
|
scroll.showsHorizontalScrollIndicator = false
|
|
scroll.showsVerticalScrollIndicator = false
|
|
scroll.alwaysBounceHorizontal = false
|
|
scroll.alwaysBounceVertical = false
|
|
scroll.contentInsetAdjustmentBehavior = .never
|
|
scroll.disablesInteractiveTransitionGestureRecognizer = true
|
|
self.addSubview(scroll)
|
|
|
|
// Layers don't autoresize with their superview; host the image layer inside a UIView
|
|
// so the scroll view's content-size growth keeps the image positioned correctly.
|
|
let imageHost = UIView(frame: CGRect(origin: .zero, size: item.scrollContentSize))
|
|
imageHost.layer.addSublayer(imageLayer)
|
|
scroll.addSubview(imageHost)
|
|
} else {
|
|
// Inline and centered-block formulas don't accept touches; the bubble's link/long-press
|
|
// recognizers run against the underlying text view instead.
|
|
self.isUserInteractionEnabled = false
|
|
self.layer.addSublayer(imageLayer)
|
|
}
|
|
}
|
|
}
|