WinterGram/submodules/InstantPageUI/Sources/InstantPageV2SlideshowView.swift
isaac ab68a1af82 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>
2026-06-02 13:50:40 +02:00

183 lines
7.7 KiB
Swift

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)
}
}
}