InstantPage V2: render collage & slideshow blocks

Port V1's collage and slideshow InstantPage blocks into the V2 renderer
(previously grey-box placeholders).

- Collage: layoutCollage computes the mosaic (MosaicLayout, the grouped-message
  engine) over inner image/video sizes and flattens it into existing top-level
  .mediaImage/.mediaVideo items + a caption, so gallery / reveal-cost / registry
  / hidden-media all handle the cells with no collage-specific code. Right-edge
  cells bleed 4pt for the bubble's rounded clip.
- Slideshow: a new .slideshow laid-out item + InstantPageV2SlideshowView, an
  eager paged carousel (UIScrollView + PageControlNode) of InstantPageImageNode
  pages, wired through frame/offsetBy/collectMedias/stableId/reuse/makeItemView
  and the reveal-cost non-text list.
- Gallery transitions generalized onto InstantPageItemView via
  instantPageTransitionNode(for:)/instantPageUpdateHiddenMedia(_:) (default
  nil/no-op; explicit per-class witnesses on the 4 static media views, the
  slideshow forwards to its live pages) so the multi-media slideshow can
  participate alongside single-media views.

Docs: document both in docs/instantpage-richtext.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-06-02 13:50:40 +02:00
parent 071799368d
commit ab68a1af82
6 changed files with 415 additions and 27 deletions

View file

@ -1,5 +1,6 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import CheckNode
import SwiftSignalKit
@ -35,7 +36,7 @@ public enum InstantPageV2StableItemId: Hashable {
}
public enum InstantPageV2ItemKind: Hashable {
case text, codeBlock, divider, listMarker, blockQuoteBar, shape, mediaPlaceholder, table, anchor, formula
case text, codeBlock, divider, listMarker, blockQuoteBar, shape, mediaPlaceholder, table, anchor, formula, slideshow
}
// MARK: - Render context
@ -679,6 +680,10 @@ public final class InstantPageV2View: UIView {
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
}
}
@ -700,6 +705,7 @@ public final class InstantPageV2View: UIView {
case .anchor: return .positional(.anchor, position)
case .formula: return .positional(.formula, position)
case .thinking: return .thinking(position)
case .slideshow: return .positional(.slideshow, position)
}
}
@ -724,13 +730,8 @@ public final class InstantPageV2View: UIView {
guard let wrapperBox = self.trueRegistryRoot.mediaRegistry[media.index], let wrapper = wrapperBox.value else {
return nil
}
let imageNode: InstantPageImageNode? =
(wrapper as? InstantPageV2MediaImageView)?.wrappedNode
?? (wrapper as? InstantPageV2MediaVideoView)?.wrappedNode
?? (wrapper as? InstantPageV2MediaMapView)?.wrappedNode
?? (wrapper as? InstantPageV2MediaCoverImageView)?.wrappedNode
guard let imageNode else { return nil }
guard let transitionNode = imageNode.transitionNode(media: media) 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)
}
@ -739,10 +740,7 @@ public final class InstantPageV2View: UIView {
func applyHiddenMedia(_ hidden: InstantPageMedia?) {
for (_, weakBox) in self.trueRegistryRoot.mediaRegistry {
guard let wrapper = weakBox.value else { continue }
if let v = wrapper as? InstantPageV2MediaImageView { v.wrappedNode.updateHiddenMedia(media: hidden) }
if let v = wrapper as? InstantPageV2MediaVideoView { v.wrappedNode.updateHiddenMedia(media: hidden) }
if let v = wrapper as? InstantPageV2MediaMapView { v.wrappedNode.updateHiddenMedia(media: hidden) }
if let v = wrapper as? InstantPageV2MediaCoverImageView { v.wrappedNode.updateHiddenMedia(media: hidden) }
(wrapper as? InstantPageItemView)?.instantPageUpdateHiddenMedia(hidden)
}
}
@ -800,6 +798,12 @@ public final class InstantPageV2View: UIView {
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)
}
}
}
@ -853,10 +857,18 @@ protocol InstantPageItemView: UIView {
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)

View file

@ -6,6 +6,7 @@ import TelegramPresentationData
import TelegramUIPreferences
import TextFormat
import TelegramStringFormatting
import MosaicLayout
// MARK: - Public layout data types
@ -49,6 +50,7 @@ public struct InstantPageV2Layout {
case let .mediaVideo(m): result.append(m.media)
case let .mediaMap(m): result.append(m.media)
case let .mediaCoverImage(m): result.append(m.media)
case let .slideshow(s): result.append(contentsOf: s.medias)
case let .details(d):
if let inner = d.innerLayout {
collectMedias(in: inner.items, into: &result)
@ -86,6 +88,7 @@ public enum InstantPageV2LaidOutItem {
case mediaCoverImage(InstantPageV2MediaCoverImageItem)
case formula(InstantPageV2FormulaItem)
case thinking(InstantPageV2ThinkingItem)
case slideshow(InstantPageV2SlideshowItem)
public var frame: CGRect {
switch self {
@ -105,6 +108,7 @@ public enum InstantPageV2LaidOutItem {
case let .mediaCoverImage(item): return item.frame
case let .formula(item): return item.frame
case let .thinking(item): return item.frame
case let .slideshow(item): return item.frame
}
}
@ -129,6 +133,7 @@ public enum InstantPageV2LaidOutItem {
case var .mediaCoverImage(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaCoverImage(item)
case var .formula(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .formula(item)
case var .thinking(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .thinking(item)
case var .slideshow(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .slideshow(item)
}
}
}
@ -290,6 +295,18 @@ public struct InstantPageV2MediaPlaceholderItem {
public let cornerRadius: CGFloat
}
public struct InstantPageV2SlideshowItem {
public var frame: CGRect
public let medias: [InstantPageMedia]
public let webPage: TelegramMediaWebpage
public init(frame: CGRect, medias: [InstantPageMedia], webPage: TelegramMediaWebpage) {
self.frame = frame
self.medias = medias
self.webPage = webPage
}
}
public struct InstantPageV2DetailsItem {
public var frame: CGRect
public let index: Int
@ -822,17 +839,13 @@ private func layoutBlock(
isCover: false, cornerRadius: 8.0, flush: true, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
case let .collage(_, caption):
return layoutMediaWithCaption(kind: .collage,
naturalSize: CGSize(width: boundingWidth, height: 240.0), caption: caption,
isCover: false, cornerRadius: 8.0, flush: true, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
case let .collage(items, caption):
return layoutCollage(items: items, caption: caption, isCover: isCover,
boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context)
case let .slideshow(_, caption):
return layoutMediaWithCaption(kind: .slideshow,
naturalSize: CGSize(width: boundingWidth, height: 240.0), caption: caption,
isCover: false, cornerRadius: 8.0, flush: true, boundingWidth: boundingWidth,
horizontalInset: horizontalInset, context: &context)
case let .slideshow(items, caption):
return layoutSlideshow(items: items, caption: caption,
boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context)
case let .channelBanner(channel):
if channel == nil { return [] }
@ -1763,6 +1776,132 @@ private func layoutTypedMediaWithCaption(
return result
}
/// Lays out an `InstantPageBlock.collage(items:caption:)`. Mirrors V1
/// (InstantPageLayout.swift:692-727): compute a mosaic over the inner image/video sizes, then emit
/// one existing typed media item per cell at its mosaic frame, flush (cornerRadius 0) so the bubble's
/// rounded clip handles the outer corners and the 1pt mosaic spacing handles the interior gaps. A
/// single caption renders below the whole mosaic. Cells are top-level `.mediaImage`/`.mediaVideo`
/// items, so gallery / reveal / registry / hidden-media all work with no extra code.
private func layoutCollage(
items innerBlocks: [InstantPageBlock],
caption: InstantPageCaption,
isCover: Bool,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
// 1. One size per inner block (zero for unresolved V1 still reserves a mosaic slot).
var itemSizes: [CGSize] = []
for block in innerBlocks {
switch block {
case let .image(id, _, _, _):
if case let .image(image) = context.media[id], let largest = largestImageRepresentation(image.representations) {
itemSizes.append(CGSize(width: CGFloat(largest.dimensions.width), height: CGFloat(largest.dimensions.height)))
} else {
itemSizes.append(CGSize())
}
case let .video(id, _, _, _):
if case let .file(file) = context.media[id], let dimensions = file.dimensions {
itemSizes.append(CGSize(width: CGFloat(dimensions.width), height: CGFloat(dimensions.height)))
} else {
itemSizes.append(CGSize())
}
default:
itemSizes.append(CGSize())
}
}
// 2. Mosaic geometry the same engine V1 uses.
let (mosaic, mosaicSize) = chatMessageBubbleMosaicLayout(maxSize: CGSize(width: boundingWidth, height: boundingWidth), itemSizes: itemSizes)
// 3. One typed media item per resolvable cell, at its mosaic frame.
var result: [InstantPageV2LaidOutItem] = []
let webpage = context.webpage
for (i, block) in innerBlocks.enumerated() {
guard i < mosaic.count else { break }
let (cellFrame, position) = mosaic[i]
// Right-edge cells bleed 4pt so the bubble's rounded clip leaves no trailing sliver.
var frame = cellFrame
if position.contains(.right) {
frame.size.width += instantPageV2MediaEdgeBleed
}
switch block {
case let .image(id, blockCaption, url, webpageId):
guard case let .image(image) = context.media[id] else { continue }
let mediaIndex = context.mediaIndexCounter
context.mediaIndexCounter += 1
let mediaUrl: InstantPageUrlItem? = url.flatMap { InstantPageUrlItem(url: $0, webpageId: webpageId) }
let media = InstantPageMedia(index: mediaIndex, media: .image(image), url: mediaUrl, caption: blockCaption.text, credit: blockCaption.credit)
result.append(.mediaImage(InstantPageV2MediaImageItem(frame: frame, cornerRadius: 0.0, media: media, webPage: webpage, attributes: [])))
case let .video(id, blockCaption, _, _):
guard case let .file(file) = context.media[id] else { continue }
let mediaIndex = context.mediaIndexCounter
context.mediaIndexCounter += 1
let media = InstantPageMedia(index: mediaIndex, media: .file(file), url: nil, caption: blockCaption.text, credit: blockCaption.credit)
result.append(.mediaVideo(InstantPageV2MediaVideoItem(frame: frame, cornerRadius: 0.0, media: media, webPage: webpage, attributes: [])))
default:
continue
}
}
// 4. Caption below the mosaic.
let (captionItems, captionHeight) = layoutCaptionAndCredit(caption, offset: mosaicSize.height, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context)
result.append(contentsOf: captionItems)
// Cover-caption padding parity with layoutTypedMediaWithCaption.
if isCover && captionHeight > 0.0 {
if let lastIndex = result.lastIndex(where: { if case .text = $0 { return true } else { return false } }) {
if case var .text(lastText) = result[lastIndex] {
lastText.frame = CGRect(origin: lastText.frame.origin, size: CGSize(width: lastText.frame.width, height: lastText.frame.height + 14.0))
result[lastIndex] = .text(lastText)
}
}
}
return result
}
/// Lays out an `InstantPageBlock.slideshow(items:caption:)`. Mirrors V1
/// (InstantPageLayout.swift:809-843): collect the inner image medias, size the block to the tallest
/// image fitted into the bounding width (cap 1200), emit a single full-width slideshow carousel item,
/// caption below. Only `.image` inner blocks contribute (matches V1).
private func layoutSlideshow(
items innerBlocks: [InstantPageBlock],
caption: InstantPageCaption,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
context: inout LayoutContext
) -> [InstantPageV2LaidOutItem] {
var medias: [InstantPageMedia] = []
var height: CGFloat = 0.0
for block in innerBlocks {
switch block {
case let .image(id, blockCaption, url, webpageId):
if case let .image(image) = context.media[id], let imageSize = largestImageRepresentation(image.representations)?.dimensions {
let mediaIndex = context.mediaIndexCounter
context.mediaIndexCounter += 1
let filledSize = imageSize.cgSize.fitted(CGSize(width: boundingWidth, height: 1200.0))
height = max(height, filledSize.height)
let mediaUrl: InstantPageUrlItem? = url.flatMap { InstantPageUrlItem(url: $0, webpageId: webpageId) }
medias.append(InstantPageMedia(index: mediaIndex, media: .image(image), url: mediaUrl, caption: blockCaption.text, credit: blockCaption.credit))
}
default:
break
}
}
var result: [InstantPageV2LaidOutItem] = []
result.append(.slideshow(InstantPageV2SlideshowItem(
frame: CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: height),
medias: medias,
webPage: context.webpage
)))
let (captionItems, _) = layoutCaptionAndCredit(caption, offset: height, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context)
result.append(contentsOf: captionItems)
return result
}
private func layoutMediaWithCaption(
kind: InstantPageV2MediaPlaceholderKind,
naturalSize: CGSize,

View file

@ -19,7 +19,7 @@ private final class WrapperRef {
// Hosts a V1 `InstantPageImageNode` inside a V2 UIView wrapper. The caller sizes its own
// frame from `item.frame` and adds the returned node's view as a subview.
private func makeMediaWrapper(
func makeMediaWrapper(
frame: CGRect,
media: InstantPageMedia,
webPage: TelegramMediaWebpage,
@ -68,14 +68,14 @@ private func findEnclosingV2View(from start: UIView?) -> InstantPageV2View? {
// its `rootMediaRegistryHost` chain transitively (nested details blocks can leave an inner
// body's host pointing at an intermediate body see `trueRegistryRoot`). No-op if the wrapper
// isn't yet attached to a V2View ancestor.
private func registerInRootRegistry(wrapper: UIView, mediaIndex: Int) {
func registerInRootRegistry(wrapper: UIView, mediaIndex: Int) {
guard let v2 = findEnclosingV2View(from: wrapper.superview) else { return }
v2.trueRegistryRoot.mediaRegistry[mediaIndex] = Weak(wrapper)
}
// Routes a tap on `tapped` through `openInstantPageMedia`, sourcing sibling medias from the
// root V2View's `currentLayout`. No-op if the wrapper isn't currently in a V2View tree.
private func handleOpenMediaTap(
func handleOpenMediaTap(
tapped: InstantPageMedia,
wrapper: UIView,
renderContext: InstantPageV2RenderContext
@ -164,6 +164,14 @@ final class InstantPageV2MediaImageView: UIView, InstantPageItemView {
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
self.wrappedNode.update(strings: strings, theme: theme)
}
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return self.wrappedNode.transitionNode(media: media)
}
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
self.wrappedNode.updateHiddenMedia(media: media)
}
}
final class InstantPageV2MediaVideoView: UIView, InstantPageItemView {
@ -219,6 +227,14 @@ final class InstantPageV2MediaVideoView: UIView, InstantPageItemView {
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
self.wrappedNode.update(strings: strings, theme: theme)
}
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return self.wrappedNode.transitionNode(media: media)
}
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
self.wrappedNode.updateHiddenMedia(media: media)
}
}
final class InstantPageV2MediaMapView: UIView, InstantPageItemView {
@ -274,6 +290,14 @@ final class InstantPageV2MediaMapView: UIView, InstantPageItemView {
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
self.wrappedNode.update(strings: strings, theme: theme)
}
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return self.wrappedNode.transitionNode(media: media)
}
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
self.wrappedNode.updateHiddenMedia(media: media)
}
}
final class InstantPageV2MediaCoverImageView: UIView, InstantPageItemView {
@ -329,4 +353,12 @@ final class InstantPageV2MediaCoverImageView: UIView, InstantPageItemView {
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
self.wrappedNode.update(strings: strings, theme: theme)
}
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
return self.wrappedNode.transitionNode(media: media)
}
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
self.wrappedNode.updateHiddenMedia(media: media)
}
}

View file

@ -334,7 +334,7 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int
// positions are identical whether or not thinking blocks are present, so adding/
// removing a thinking block never jumps the answer's reveal position.
entries.append(.thinking(start: cursor))
case .formula, .mediaImage, .mediaVideo, .mediaMap, .mediaCoverImage, .mediaPlaceholder,
case .formula, .mediaImage, .mediaVideo, .mediaMap, .mediaCoverImage, .mediaPlaceholder, .slideshow,
.divider, .listMarker, .blockQuoteBar, .shape, .anchor:
let start = cursor
cursor += itemWidthCost(item)

View file

@ -0,0 +1,183 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
import AccountContext
import TelegramCore
import TelegramPresentationData
// A paged carousel for an `InstantPageBlock.slideshow`. Ports V1's InstantPageSlideshowNode /
// InstantPageSlideshowPagerNode (InstantPageSlideshowItemNode.swift), simplified to create all pages
// eagerly (slideshows are short; this avoids V1's central±1 index bookkeeping and makes the gallery
// transition source available for every page). Each image page hosts an `InstantPageImageNode` exactly
// like the static media views; non-image medias render an empty page (matches V1).
final class InstantPageV2SlideshowView: UIView, InstantPageItemView, UIScrollViewDelegate {
private(set) var item: InstantPageV2SlideshowItem
var itemFrame: CGRect { return self.item.frame }
private let renderContext: InstantPageV2RenderContext
private var theme: InstantPageTheme
private let scrollView: UIScrollView
private let pageControlNode: PageControlNode
// One wrapper view per media (so page count stays aligned with the page control). `pageImageNodes`
// holds only the real image nodes; it may be shorter than `pageViews` if a non-image media appears
// (which `layoutSlideshow` currently filters out). Nothing relies on positional correspondence.
private var pageViews: [UIView] = []
private var pageImageNodes: [InstantPageImageNode] = []
init(item: InstantPageV2SlideshowItem, renderContext: InstantPageV2RenderContext, theme: InstantPageTheme) {
self.item = item
self.renderContext = renderContext
self.theme = theme
self.scrollView = UIScrollView()
self.pageControlNode = PageControlNode(dotColor: .white, inactiveDotColor: UIColor(white: 1.0, alpha: 0.5))
super.init(frame: item.frame)
self.backgroundColor = theme.panelSecondaryColor // structural
self.clipsToBounds = true // structural
self.scrollView.isPagingEnabled = true
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.scrollsToTop = false
if #available(iOS 11.0, *) {
self.scrollView.contentInsetAdjustmentBehavior = .never
}
self.scrollView.delegate = self
self.addSubview(self.scrollView) // structural
self.pageControlNode.isUserInteractionEnabled = false
self.addSubview(self.pageControlNode.view) // structural
self.rebuildPages()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func rebuildPages() {
for view in self.pageViews {
view.removeFromSuperview()
}
self.pageViews = []
self.pageImageNodes = []
let renderContext = self.renderContext
// The image node owns this closure, and is owned (transitively) by self capture weakly.
let openMedia: (InstantPageMedia) -> Void = { [weak self] tapped in
guard let self else { return }
handleOpenMediaTap(tapped: tapped, wrapper: self, renderContext: renderContext)
}
for media in self.item.medias {
let pageView = UIView()
pageView.clipsToBounds = true
if case .image = media.media {
let node = makeMediaWrapper(
frame: CGRect(origin: .zero, size: self.item.frame.size),
media: media,
webPage: self.item.webPage,
attributes: [],
renderContext: self.renderContext,
theme: self.theme,
openMedia: openMedia,
longPressMedia: { _ in }
)
pageView.addSubview(node.view)
self.pageImageNodes.append(node)
}
// Non-image medias (none in practice layoutSlideshow filters to images) get an empty page
// to keep page indices aligned with the page control.
self.scrollView.addSubview(pageView)
self.pageViews.append(pageView)
}
self.pageControlNode.pagesCount = self.item.medias.count
self.pageControlNode.setPage(0.0)
// Re-register media indices when rebuilding while already on-window (positional reuse with
// changed content); no-ops before the view is attached, where didMoveToWindow handles it.
self.registerMedias()
self.setNeedsLayout()
}
private func registerMedias() {
guard self.window != nil else { return }
// Register under every contained media index so transitionArgsFor(media) can find this view.
for media in self.item.medias {
registerInRootRegistry(wrapper: self, mediaIndex: media.index)
}
}
override func didMoveToWindow() {
super.didMoveToWindow()
self.registerMedias()
}
override func layoutSubviews() {
super.layoutSubviews()
let size = self.bounds.size
guard size.width > 0.0, size.height > 0.0 else { return }
self.scrollView.frame = CGRect(origin: .zero, size: size)
for (i, pageView) in self.pageViews.enumerated() {
pageView.frame = CGRect(x: CGFloat(i) * size.width, y: 0.0, width: size.width, height: size.height)
}
for node in self.pageImageNodes {
node.frame = CGRect(origin: .zero, size: size)
}
self.scrollView.contentSize = CGSize(width: CGFloat(self.pageViews.count) * size.width, height: size.height)
self.pageControlNode.layer.transform = CATransform3DIdentity
self.pageControlNode.frame = CGRect(x: 0.0, y: size.height - 20.0, width: size.width, height: 20.0)
let maxWidth = size.width - 36.0
let pageControlSize = self.pageControlNode.calculateSizeThatFits(size)
if pageControlSize.width > maxWidth, pageControlSize.width > 0.0 {
let scale = maxWidth / pageControlSize.width
self.pageControlNode.layer.transform = CATransform3DMakeScale(scale, scale, 1.0)
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let width = self.bounds.size.width
guard width > 0.0, !self.item.medias.isEmpty else { return }
let page = Int((scrollView.contentOffset.x + width / 2.0) / width)
let clamped = max(0, min(self.item.medias.count - 1, page))
self.pageControlNode.setPage(CGFloat(clamped))
}
func update(item: InstantPageV2SlideshowItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext) {
let mediasChanged = self.item.medias.map { $0.index } != item.medias.map { $0.index }
self.item = item
self.theme = theme
self.backgroundColor = theme.panelSecondaryColor
if mediasChanged {
self.rebuildPages()
} else {
let strings = renderContext.context.sharedContext.currentPresentationData.with { $0 }.strings
for node in self.pageImageNodes {
node.update(strings: strings, theme: theme)
}
}
self.setNeedsLayout()
}
// MARK: InstantPageItemView gallery hooks
func instantPageTransitionNode(for media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
for node in self.pageImageNodes {
if let transition = node.transitionNode(media: media) {
return transition
}
}
return nil
}
func instantPageUpdateHiddenMedia(_ media: InstantPageMedia?) {
for node in self.pageImageNodes {
node.updateHiddenMedia(media: media)
}
}
}