mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 06:14:14 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
9.5 KiB
Swift
204 lines
9.5 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Display
|
|
import AsyncDisplayKit
|
|
import AppBundle
|
|
import TelegramPresentationData
|
|
import WallpaperBackgroundNode
|
|
|
|
/// The unsupported-content pill: a rounded card carrying a badge, a title/subtitle column and a
|
|
/// trailing action button, over either a live wallpaper patch or a static fill.
|
|
///
|
|
/// The view does NOT write its own `frame` — the host positions it and passes the size it chose.
|
|
public final class UnsupportedContentPillView: UIView {
|
|
private let backgroundColorView: UIView
|
|
private var wallpaperBackgroundContent: WallpaperBubbleBackgroundNode?
|
|
private weak var currentWallpaperBackgroundNode: WallpaperBackgroundNode?
|
|
|
|
private let badgeBackgroundView: UIImageView
|
|
private let badgeIconView: UIImageView
|
|
|
|
private var titleNode: TextNode?
|
|
private var subtitleNode: TextNode?
|
|
private var buttonTitleNode: TextNode?
|
|
|
|
private let buttonNode: HighlightTrackingButton
|
|
|
|
private var buttonFrame: CGRect = CGRect()
|
|
|
|
/// Invoked when the trailing button is tapped. Hosts that leave this nil still render the
|
|
/// button; the tap simply does nothing.
|
|
public var action: (() -> Void)?
|
|
|
|
public override init(frame: CGRect) {
|
|
self.backgroundColorView = UIView()
|
|
self.backgroundColorView.clipsToBounds = true
|
|
|
|
self.badgeBackgroundView = UIImageView()
|
|
self.badgeBackgroundView.contentMode = .scaleAspectFit
|
|
self.badgeBackgroundView.image = UIImage(bundleImageName: "Chat/Message/UnsupportedIconBackground")?.withRenderingMode(.alwaysTemplate)
|
|
|
|
self.badgeIconView = UIImageView()
|
|
self.badgeIconView.contentMode = .scaleAspectFit
|
|
self.badgeIconView.image = UIImage(bundleImageName: "Chat/Message/UnsupportedIcon")?.withRenderingMode(.alwaysTemplate)
|
|
|
|
self.buttonNode = HighlightTrackingButton()
|
|
self.buttonNode.clipsToBounds = true
|
|
|
|
super.init(frame: frame)
|
|
|
|
self.addSubview(self.backgroundColorView)
|
|
self.addSubview(self.badgeBackgroundView)
|
|
self.addSubview(self.badgeIconView)
|
|
self.addSubview(self.buttonNode)
|
|
|
|
self.buttonNode.highligthedChanged = { [weak self] highlighted in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if highlighted {
|
|
self.buttonNode.layer.removeAnimation(forKey: "opacity")
|
|
self.buttonNode.alpha = 0.6
|
|
} else {
|
|
self.buttonNode.alpha = 1.0
|
|
self.buttonNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
|
|
}
|
|
}
|
|
self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), for: .touchUpInside)
|
|
}
|
|
|
|
public convenience init() {
|
|
self.init(frame: CGRect())
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
@objc private func buttonPressed() {
|
|
self.action?()
|
|
}
|
|
|
|
/// True when `point` (in this view's coordinate space) is inside the action button. For hosts
|
|
/// that arbitrate taps themselves rather than letting the button receive them.
|
|
public func actionContains(_ point: CGPoint) -> Bool {
|
|
return self.buttonFrame.contains(point)
|
|
}
|
|
|
|
public func update(
|
|
layout: UnsupportedContentPillLayout,
|
|
colors: UnsupportedContentPillColors,
|
|
strings: UnsupportedContentPillStrings,
|
|
size: CGSize,
|
|
wallpaperBackgroundNode: WallpaperBackgroundNode?,
|
|
animation: ListViewItemUpdateAnimation
|
|
) {
|
|
// Re-derive the text through the SAME measure function the host used, against our own
|
|
// nodes so they are reused rather than rebuilt.
|
|
let (_, apply) = measureUnsupportedContentPill(
|
|
strings: strings,
|
|
colors: colors,
|
|
constrainedWidth: layout.constrainedWidth,
|
|
titleNode: self.titleNode,
|
|
subtitleNode: self.subtitleNode,
|
|
buttonTitleNode: self.buttonTitleNode
|
|
)
|
|
let titleNode = apply.title()
|
|
let subtitleNode = apply.subtitle()
|
|
let buttonTitleNode = apply.buttonTitle()
|
|
if titleNode !== self.titleNode {
|
|
self.titleNode?.view.removeFromSuperview()
|
|
self.titleNode = titleNode
|
|
self.addSubview(titleNode.view)
|
|
}
|
|
if subtitleNode !== self.subtitleNode {
|
|
self.subtitleNode?.view.removeFromSuperview()
|
|
self.subtitleNode = subtitleNode
|
|
self.addSubview(subtitleNode.view)
|
|
}
|
|
if buttonTitleNode !== self.buttonTitleNode {
|
|
self.buttonTitleNode?.view.removeFromSuperview()
|
|
self.buttonTitleNode = buttonTitleNode
|
|
// LOAD-BEARING for the tap, not just a tidiness flag. An `ASDisplayNode`'s view is
|
|
// interactive by default, so an interactive label would be the deepest hit-test result
|
|
// inside the button — and Telegram's bubble-wide tap recognizer only steps aside when
|
|
// the hit-test result IS a `UIButton`
|
|
// (`TapLongTapOrDoubleTapGestureRecognizer.touchesBegan`). With the label winning the
|
|
// hit test the recognizer claims the touch instead and cancels the button's tracking,
|
|
// so `touchUpInside` never fires and the pill reads as dead.
|
|
buttonTitleNode.isUserInteractionEnabled = false
|
|
self.buttonNode.addSubview(buttonTitleNode.view)
|
|
}
|
|
|
|
let backgroundFrame = CGRect(origin: CGPoint(), size: size)
|
|
let cornerRadius = min(size.height * 0.5, pillMaximumCornerRadius)
|
|
|
|
// Rebuild the wallpaper child when the host's background node identity changes; a portal
|
|
// view mirrors ITS source, so a stale one would mirror a dead wallpaper.
|
|
if wallpaperBackgroundNode !== self.currentWallpaperBackgroundNode {
|
|
self.wallpaperBackgroundContent?.removeFromSupernode()
|
|
self.wallpaperBackgroundContent = nil
|
|
self.currentWallpaperBackgroundNode = wallpaperBackgroundNode
|
|
}
|
|
if self.wallpaperBackgroundContent == nil, let backgroundContent = wallpaperBackgroundNode?.makeBubbleBackground(for: .free) {
|
|
self.wallpaperBackgroundContent = backgroundContent
|
|
self.insertSubview(backgroundContent.view, at: 0)
|
|
}
|
|
|
|
if let backgroundContent = self.wallpaperBackgroundContent {
|
|
self.backgroundColorView.isHidden = true
|
|
backgroundContent.clipsToBounds = true
|
|
backgroundContent.cornerRadius = cornerRadius
|
|
animation.animator.updateFrame(layer: backgroundContent.layer, frame: backgroundFrame, completion: nil)
|
|
} else {
|
|
self.backgroundColorView.isHidden = false
|
|
self.backgroundColorView.backgroundColor = colors.fill
|
|
self.backgroundColorView.layer.cornerRadius = cornerRadius
|
|
animation.animator.updateFrame(layer: self.backgroundColorView.layer, frame: backgroundFrame, completion: nil)
|
|
}
|
|
|
|
// The badge reads as a recess and the button as a raised surface, so they
|
|
// move in opposite directions from the background rather than sharing a fill.
|
|
let badgeFrame = CGRect(
|
|
origin: CGPoint(x: pillContentInsets.left, y: floorToScreenPixels((size.height - pillBadgeDiameter) / 2.0)),
|
|
size: CGSize(width: pillBadgeDiameter, height: pillBadgeDiameter)
|
|
)
|
|
animation.animator.updateFrame(layer: self.badgeBackgroundView.layer, frame: badgeFrame, completion: nil)
|
|
self.badgeBackgroundView.tintColor = UIColor(rgb: 0x000000)
|
|
self.badgeBackgroundView.alpha = colors.isDark ? pillBadgeBackgroundAlpha.dark : pillBadgeBackgroundAlpha.light
|
|
|
|
// Same frame as the background: the plane's position inside the bubble is
|
|
// baked into the asset, so any inset here would push it off centre.
|
|
animation.animator.updateFrame(layer: self.badgeIconView.layer, frame: badgeFrame, completion: nil)
|
|
self.badgeIconView.tintColor = colors.primaryText
|
|
|
|
let textColumnX = badgeFrame.maxX + pillBadgeTextSpacing
|
|
let textColumnY = floorToScreenPixels((size.height - layout.textColumnSize.height) / 2.0)
|
|
|
|
let titleFrame = CGRect(origin: CGPoint(x: textColumnX, y: textColumnY), size: layout.titleSize)
|
|
animation.animator.updateFrame(layer: titleNode.layer, frame: titleFrame, completion: nil)
|
|
|
|
let subtitleFrame = CGRect(origin: CGPoint(x: textColumnX, y: titleFrame.maxY + pillTitleSubtitleSpacing), size: layout.subtitleSize)
|
|
animation.animator.updateFrame(layer: subtitleNode.layer, frame: subtitleFrame, completion: nil)
|
|
|
|
let buttonFrame = layout.actionFrame(in: size)
|
|
self.buttonFrame = buttonFrame
|
|
animation.animator.updateFrame(layer: self.buttonNode.layer, frame: buttonFrame, completion: nil)
|
|
|
|
self.buttonNode.layer.cornerRadius = layout.buttonSize.height * 0.5
|
|
self.buttonNode.backgroundColor = UIColor(rgb: colors.isDark ? 0xffffff : 0x000000, alpha: 0.12)
|
|
self.buttonNode.accessibilityLabel = strings.action
|
|
|
|
// From the measured layout, NOT from the node's own bounds — an applied TextNode has zero
|
|
// bounds until this assignment, so reading them here would draw an empty label.
|
|
let buttonTitleSize = layout.buttonTitleSize
|
|
buttonTitleNode.frame = CGRect(
|
|
origin: CGPoint(
|
|
x: floorToScreenPixels((layout.buttonSize.width - buttonTitleSize.width) / 2.0),
|
|
y: floorToScreenPixels((layout.buttonSize.height - buttonTitleSize.height) / 2.0)
|
|
),
|
|
size: buttonTitleSize
|
|
)
|
|
}
|
|
}
|