UIKit миграция ChatDetailView + pinned header fraction fix + tab bar visibility

This commit is contained in:
2026-04-15 14:51:07 +05:00
parent 03c556f77e
commit 8561fecbfc
8 changed files with 538 additions and 232 deletions

View File

@@ -216,18 +216,19 @@ final class ChatDetailViewController: UIViewController {
let safeTop = view.safeAreaInsets.top
let centerY = safeTop + headerBarHeight * 0.5
// Back button (left)
// Back button (left) 8pt from edge
let sideMargin: CGFloat = 8
let backSize = backButton.intrinsicContentSize
backButton.frame = CGRect(
x: 6,
x: sideMargin,
y: centerY - backSize.height * 0.5,
width: backSize.width,
height: backSize.height
)
// Avatar (right) 44×44 glass circle with 38pt avatar inside
// Avatar (right) same margin as back button
let avatarOuterSize: CGFloat = 44
let avatarRight: CGFloat = 16
let avatarRight: CGFloat = sideMargin
avatarButton.frame = CGRect(
x: view.bounds.width - avatarRight - avatarOuterSize,
y: centerY - avatarOuterSize * 0.5,
@@ -235,15 +236,24 @@ final class ChatDetailViewController: UIViewController {
height: avatarOuterSize
)
// Title pill (center, between back and avatar)
let titleLeft = backButton.frame.maxX + 8
let titleRight = avatarButton.frame.minX - 8
let titleWidth = titleRight - titleLeft
// Title pill (content-sized, centered between back and avatar)
let titleHeight: CGFloat = 44
let hPad: CGFloat = 16 // horizontal padding inside pill
let titlePillRef = titlePill as! ChatDetailTitlePill
let contentWidth = titlePillRef.contentWidth()
let pillWidth = max(120, contentWidth + hPad * 2)
// Available zone between back and avatar
let zoneLeft = backButton.frame.maxX + 8
let zoneRight = avatarButton.frame.minX - 8
let zoneWidth = zoneRight - zoneLeft
// Clamp pill to available zone, center within it
let clampedWidth = min(pillWidth, zoneWidth)
let pillX = zoneLeft + (zoneWidth - clampedWidth) / 2
titlePill.frame = CGRect(
x: titleLeft,
x: pillX,
y: centerY - titleHeight * 0.5,
width: titleWidth,
width: clampedWidth,
height: titleHeight
)
}
@@ -532,13 +542,18 @@ final class ChatDetailViewController: UIViewController {
private func openProfile() {
view.endEditing(true)
// Show nav bar BEFORE push prevents jump from hiddenvisible during animation.
// Profile uses .toolbarBackground(.hidden) so it's visually invisible anyway.
navigationController?.setNavigationBarHidden(false, animated: false)
if route.isGroup {
let groupInfo = GroupInfoView(groupDialogKey: route.publicKey)
let hosting = UIHostingController(rootView: groupInfo)
hosting.navigationItem.hidesBackButton = true // prevent system "< Back" flash
navigationController?.pushViewController(hosting, animated: true)
} else if !route.isSystemAccount {
let profile = OpponentProfileView(route: route)
let hosting = UIHostingController(rootView: profile)
hosting.navigationItem.hidesBackButton = true // prevent system "< Back" flash
navigationController?.pushViewController(hosting, animated: true)
}
}
@@ -568,8 +583,10 @@ final class ChatDetailViewController: UIViewController {
} else {
profileRoute = ChatRoute(publicKey: senderKey, title: String(senderKey.prefix(8)), username: "", verified: 0)
}
navigationController?.setNavigationBarHidden(false, animated: false)
let profile = OpponentProfileView(route: profileRoute)
let hosting = UIHostingController(rootView: profile)
hosting.navigationItem.hidesBackButton = true
navigationController?.pushViewController(hosting, animated: true)
}
@@ -1152,6 +1169,13 @@ private final class ChatDetailTitlePill: UIControl {
subtitleLabel.textColor = subtitleColor
}
/// Returns the natural content width (max of title/subtitle label widths).
func contentWidth() -> CGFloat {
let titleW = titleLabel.intrinsicContentSize.width
let subtitleW = subtitleLabel.intrinsicContentSize.width
return max(titleW, subtitleW)
}
override func layoutSubviews() {
super.layoutSubviews()
glassView.frame = bounds
@@ -1186,6 +1210,7 @@ private final class ChatDetailTitlePill: UIControl {
private final class ChatDetailAvatarButton: UIControl {
private let glassView = TelegramGlassUIView(frame: .zero)
private let avatarBackgroundView = UIView()
private let avatarImageView = UIImageView()
private let initialsLabel = UILabel()
private let route: ChatRoute
@@ -1196,7 +1221,6 @@ private final class ChatDetailAvatarButton: UIControl {
setupUI()
updateAvatar()
// Observe avatar changes
NotificationCenter.default.addObserver(
self, selector: #selector(avatarChanged),
name: .init("AvatarRepositoryDidChange"), object: nil
@@ -1207,13 +1231,20 @@ private final class ChatDetailAvatarButton: UIControl {
required init?(coder: NSCoder) { fatalError() }
private func setupUI() {
// All subviews must NOT intercept touches UIControl handles them
glassView.isUserInteractionEnabled = false
addSubview(glassView)
avatarBackgroundView.isUserInteractionEnabled = false
avatarBackgroundView.clipsToBounds = true
addSubview(avatarBackgroundView)
avatarImageView.isUserInteractionEnabled = false
avatarImageView.contentMode = .scaleAspectFill
avatarImageView.clipsToBounds = true
addSubview(avatarImageView)
initialsLabel.isUserInteractionEnabled = false
initialsLabel.font = .systemFont(ofSize: 16, weight: .medium)
initialsLabel.textColor = .white
initialsLabel.textAlignment = .center
@@ -1238,8 +1269,19 @@ private final class ChatDetailAvatarButton: UIControl {
: route.isGroup ? RosettaColors.groupInitial(name: title, publicKey: route.publicKey)
: RosettaColors.initials(name: title, publicKey: route.publicKey)
// Mantine "light" variant (ChatListCell parity)
let colorIndex = RosettaColors.avatarColorIndex(for: title, publicKey: route.publicKey)
backgroundColor = RosettaColors.avatarColor(for: colorIndex)
let colorPair = RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count]
let isDark = traitCollection.userInterfaceStyle == .dark
let mantineDarkBody = UIColor(red: 0x1A / 255, green: 0x1B / 255, blue: 0x1E / 255, alpha: 1)
let baseColor = isDark ? mantineDarkBody : .white
let tintUIColor = UIColor(colorPair.tint)
let tintAlpha: CGFloat = isDark ? 0.15 : 0.10
avatarBackgroundView.backgroundColor = baseColor.chatDetailBlended(with: tintUIColor, alpha: tintAlpha)
// Font: bold rounded, 38 * 0.38 14.4pt
initialsLabel.font = UIFont.systemFont(ofSize: 38 * 0.38, weight: .bold).chatDetailRounded()
initialsLabel.textColor = isDark ? UIColor(colorPair.text) : tintUIColor
}
}
@@ -1259,12 +1301,13 @@ private final class ChatDetailAvatarButton: UIControl {
let pad = (bounds.width - avatarDiam) / 2
let avatarFrame = CGRect(x: pad, y: pad, width: avatarDiam, height: avatarDiam)
avatarBackgroundView.frame = avatarFrame
avatarBackgroundView.layer.cornerRadius = avatarDiam * 0.5
avatarImageView.frame = avatarFrame
avatarImageView.layer.cornerRadius = avatarDiam * 0.5
initialsLabel.frame = avatarFrame
layer.cornerRadius = bounds.height * 0.5
clipsToBounds = true
}
override var isHighlighted: Bool {
@@ -1272,6 +1315,31 @@ private final class ChatDetailAvatarButton: UIControl {
}
}
// MARK: - UIFont/UIColor Helpers (ChatListCell parity)
private extension UIFont {
func chatDetailRounded() -> UIFont {
guard let descriptor = fontDescriptor.withDesign(.rounded) else { return self }
return UIFont(descriptor: descriptor, size: 0)
}
}
private extension UIColor {
func chatDetailBlended(with color: UIColor, alpha: CGFloat) -> UIColor {
var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0
var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0
getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
color.getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
let inv = 1.0 - alpha
return UIColor(
red: r1 * inv + r2 * alpha,
green: g1 * inv + g2 * alpha,
blue: b1 * inv + b2 * alpha,
alpha: a1 * inv + a2 * alpha
)
}
}
// MARK: - UIGestureRecognizerDelegate (Full-Width Swipe Back)
extension ChatDetailViewController: UIGestureRecognizerDelegate {

View File

@@ -93,12 +93,15 @@ final class NativeMessageCell: UICollectionViewCell {
// Telegram-exact stretchable bubble images (raster, not vector only way to get exact tail).
// `var` so they can be regenerated on theme switch (colors baked into raster at generation time).
private static var bubbleImages = BubbleImageFactory.generate(
private(set) static var bubbleImages = BubbleImageFactory.generate(
outgoingColor: outgoingColor,
incomingColor: incomingColor
)
private static var bubbleImagesStyle: UIUserInterfaceStyle = .unspecified
/// Exposed for skeleton loading same stretchable images used in real cells.
static var currentBubbleImages: BubbleImageFactory.ImageSet? { bubbleImages }
/// Regenerate cached bubble images after theme change.
/// Must be called on main thread. `performAsCurrent` ensures dynamic
/// `incomingColor` resolves with the correct light/dark traits.
@@ -2936,6 +2939,10 @@ final class NativeMessageCell: UICollectionViewCell {
layer.removeAnimation(forKey: "insertionSlide")
layer.removeAnimation(forKey: "insertionMove")
contentView.layer.removeAnimation(forKey: "insertionAlpha")
layer.removeAnimation(forKey: "skeletonScale")
layer.removeAnimation(forKey: "skeletonPositionX")
layer.removeAnimation(forKey: "skeletonPositionY")
layer.removeAnimation(forKey: "skeletonFadeIn")
dateHeaderContainer.isHidden = true
dateHeaderLabel.text = nil
isInlineDateHeaderHidden = false

View File

@@ -270,23 +270,65 @@ final class NativeMessageListController: UIViewController {
private func showSkeleton() {
guard skeletonView == nil else { return }
let skeleton = NativeSkeletonView()
skeleton.chatType = config.isGroupChat ? .group : .user
skeleton.clipsToBounds = true
skeleton.frame = view.bounds
skeleton.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(skeleton)
// Insert below composer so it never overlaps the input bar
if let composer = composerView {
view.insertSubview(skeleton, belowSubview: composer)
} else {
view.addSubview(skeleton)
}
skeletonView = skeleton
isShowingSkeleton = true
}
private func hideSkeletonAnimated() {
/// Update skeleton bottom inset after layout (safe area + composer known).
private func updateSkeletonInset() {
guard let skeleton = skeletonView else { return }
let composerH = max(lastComposerHeight, 60)
let safeBottom = view.safeAreaInsets.bottom
skeleton.bottomInset = composerH + safeBottom + 8
}
private func hideSkeletonAnimated() {
isShowingSkeleton = false
guard let skeleton = skeletonView else { return }
skeletonView = nil
skeleton.animateOut {
skeleton.removeFromSuperview()
// Gather visible cells for Telegram-exact fly-in animation
var cellInfos: [(cell: UIView, isOutgoing: Bool)] = []
let sortedIndexPaths = collectionView.indexPathsForVisibleItems
.sorted { $0.item < $1.item }
for ip in sortedIndexPaths {
guard let cell = collectionView.cellForItem(at: ip) as? NativeMessageCell,
let msgId = dataSource.itemIdentifier(for: ip) else { continue }
let isOutgoing = layoutCache[msgId]?.isOutgoing ?? false
// Hide cells before animation they'll fade in via skeleton transition
// Animate on cell.layer (NOT contentView) to avoid conflicting with contentView's y:-1 flip
cell.layer.opacity = 0
cellInfos.append((cell, isOutgoing))
}
// Fallback: force remove after 1s if animation didn't complete
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak skeleton] in
let heightNorm = collectionView.bounds.height - collectionView.contentInset.top
if cellInfos.isEmpty {
// Fallback: no visible cells simple fade
skeleton.animateOut {
skeleton.removeFromSuperview()
}
} else {
skeleton.animateOutWithCells(cellInfos, heightNorm: heightNorm) {
skeleton.removeFromSuperview()
}
}
// Fallback: force remove after 1.5s if animation didn't complete
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak skeleton] in
skeleton?.removeFromSuperview()
}
}
@@ -330,6 +372,9 @@ final class NativeMessageListController: UIViewController {
// Date pills sit between collection view and composer (z-order).
// Composer covers pills naturally no bringSubviewToFront needed.
// Update skeleton inset now that safe area and composer height are known.
updateSkeletonInset()
}
override func viewSafeAreaInsetsDidChange() {
@@ -1061,10 +1106,8 @@ final class NativeMessageListController: UIViewController {
/// Called from SwiftUI when messages array changes.
func update(messages: [ChatMessage], animated: Bool = false) {
// Hide skeleton on first message arrival
if isShowingSkeleton && !messages.isEmpty {
hideSkeletonAnimated()
}
// Defer skeleton dismiss until after snapshot is applied (cells must exist for fly-in animation)
let shouldDismissSkeleton = isShowingSkeleton && !messages.isEmpty
let oldIds = Set(self.messages.map(\.id))
let oldNewestId = self.messages.last?.id
@@ -1150,6 +1193,12 @@ final class NativeMessageListController: UIViewController {
dataSource.apply(snapshot, animatingDifferences: false)
// Dismiss skeleton AFTER snapshot applied cells now exist for fly-in animation
if shouldDismissSkeleton {
collectionView.layoutIfNeeded()
hideSkeletonAnimated()
}
// Apply Telegram-style insertion animations after layout settles
if isInteractive {
collectionView.layoutIfNeeded()

View File

@@ -2,66 +2,120 @@ import UIKit
// MARK: - NativeSkeletonView
/// Telegram-quality skeleton loading for chat message list.
/// Shows 14 incoming bubble placeholders stacked from bottom up with shimmer animation.
/// Telegram parity: ChatLoadingNode.swift 14 bubbles, shimmer via screenBlendMode.
/// Skeleton loading for chat message list.
/// Shows placeholder bubbles that look like real messages (same colors, tails, stretchable images)
/// with a shimmer overlay. Mix of incoming/outgoing for 1-on-1, all incoming for groups.
final class NativeSkeletonView: UIView {
// MARK: - Telegram-Exact Dimensions
// MARK: - Chat Type
private static let shortHeight: CGFloat = 71
private static let tallHeight: CGFloat = 93
private static let avatarSize: CGFloat = 38
private static let avatarLeftInset: CGFloat = 8
private static let bubbleLeftInset: CGFloat = 54 // avatar + spacing
private static let verticalGap: CGFloat = 4 // Small gap between skeleton bubbles
private static let initialBottomOffset: CGFloat = 5
enum ChatType { case user, group }
/// Telegram-exact width fractions and heights for 14 skeleton bubbles.
private static let bubbleSpecs: [(widthFrac: CGFloat, height: CGFloat)] = [
(0.47, tallHeight), (0.58, tallHeight), (0.69, tallHeight), (0.47, tallHeight),
(0.58, shortHeight), (0.36, tallHeight), (0.47, tallHeight), (0.36, shortHeight),
(0.58, tallHeight), (0.69, tallHeight), (0.58, tallHeight), (0.36, shortHeight),
(0.47, tallHeight), (0.58, tallHeight),
var chatType: ChatType = .group {
didSet {
guard chatType != oldValue else { return }
setNeedsLayout()
}
}
/// Bottom inset to avoid overlapping with composer bar.
var bottomInset: CGFloat = 0 {
didSet {
guard bottomInset != oldValue else { return }
setNeedsLayout()
}
}
// MARK: - Bubble Specs
/// (widthFraction, height, isOutgoing) outgoing only used for 1-on-1 chats.
private struct BubbleSpec {
let widthFrac: CGFloat
let height: CGFloat
let outgoing: Bool
}
// 1-on-1: mix of incoming/outgoing (like a real conversation)
private static let userSpecs: [BubbleSpec] = [
BubbleSpec(widthFrac: 0.55, height: 44, outgoing: true),
BubbleSpec(widthFrac: 0.45, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.65, height: 44, outgoing: true),
BubbleSpec(widthFrac: 0.50, height: 60, outgoing: false),
BubbleSpec(widthFrac: 0.40, height: 44, outgoing: true),
BubbleSpec(widthFrac: 0.60, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.50, height: 44, outgoing: true),
BubbleSpec(widthFrac: 0.55, height: 60, outgoing: false),
BubbleSpec(widthFrac: 0.45, height: 44, outgoing: true),
BubbleSpec(widthFrac: 0.50, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.60, height: 44, outgoing: true),
BubbleSpec(widthFrac: 0.40, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.55, height: 60, outgoing: true),
BubbleSpec(widthFrac: 0.50, height: 44, outgoing: false),
]
// MARK: - Shimmer Parameters (Telegram-exact)
// Groups: all incoming (Telegram parity)
private static let groupSpecs: [BubbleSpec] = [
BubbleSpec(widthFrac: 0.47, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.69, height: 60, outgoing: false),
BubbleSpec(widthFrac: 0.47, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.36, height: 60, outgoing: false),
BubbleSpec(widthFrac: 0.47, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.36, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.69, height: 60, outgoing: false),
BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.36, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.47, height: 44, outgoing: false),
BubbleSpec(widthFrac: 0.58, height: 44, outgoing: false),
]
private static let avatarSize: CGFloat = 38
private static let avatarLeftInset: CGFloat = 8
private static let bubbleLeftInsetWithAvatar: CGFloat = 54
private static let bubbleLeftInsetNoAvatar: CGFloat = 10
private static let bubbleRightInset: CGFloat = 10
private static let verticalGap: CGFloat = 4
private static let initialBottomOffset: CGFloat = 5
// MARK: - Shimmer Parameters
private static let shimmerDuration: CFTimeInterval = 1.6
private static let shimmerEffectSize: CGFloat = 280
private static let shimmerOpacity: CGFloat = 0.14
private static let borderShimmerEffectSize: CGFloat = 320
private static let borderShimmerOpacity: CGFloat = 0.35
// MARK: - Bubble Info (for skeletonreal cell matching)
struct BubbleInfo {
let frame: CGRect
let avatarFrame: CGRect?
}
private(set) var bubbleInfos: [BubbleInfo] = []
// MARK: - Subviews
private let containerView = UIView()
private var bubbleLayers: [CAShapeLayer] = []
private var avatarLayers: [CAShapeLayer] = []
private var shimmerLayer: CALayer?
private var borderShimmerLayer: CALayer?
private var bubbleImageViews: [UIImageView] = []
private var avatarViews: [UIView] = []
private var shimmerGradients: [CAGradientLayer] = []
private var isShimmerRunning = false
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Setup
private func setup() {
backgroundColor = .clear
isUserInteractionEnabled = false
containerView.frame = bounds
containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(containerView)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Layout
override func layoutSubviews() {
@@ -71,213 +125,254 @@ final class NativeSkeletonView: UIView {
}
private func rebuildBubbles() {
// Remove old layers
bubbleLayers.forEach { $0.removeFromSuperlayer() }
avatarLayers.forEach { $0.removeFromSuperlayer() }
bubbleLayers.removeAll()
avatarLayers.removeAll()
// Clean up
bubbleImageViews.forEach { $0.removeFromSuperview() }
avatarViews.forEach { $0.removeFromSuperview() }
bubbleImageViews.removeAll()
avatarViews.removeAll()
bubbleInfos.removeAll()
shimmerLayer?.removeFromSuperlayer()
borderShimmerLayer?.removeFromSuperlayer()
let width = bounds.width
let height = bounds.height
guard width > 0, height > 0 else { return }
let specs = chatType == .group ? Self.groupSpecs : Self.userSpecs
let hasAvatar = chatType == .group
// Get real bubble images from NativeMessageCell's factory
let bubbleImages = NativeMessageCell.currentBubbleImages
// Avatar color
let themeMode = UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "dark"
let isDark = themeMode != "light"
let bubbleColor = isDark
? UIColor.gray.withAlphaComponent(0.08)
: UIColor.gray.withAlphaComponent(0.10)
let avatarColor = isDark
? UIColor.gray.withAlphaComponent(0.06)
: UIColor.gray.withAlphaComponent(0.08)
? UIColor.gray.withAlphaComponent(0.12)
: UIColor.gray.withAlphaComponent(0.15)
// Build mask from all bubbles combined
// Combined mask for shimmer overlay (bubble-shaped, not rectangular)
let combinedMaskPath = CGMutablePath()
let combinedBorderMaskPath = CGMutablePath()
// Stack from bottom up
var y = height - Self.initialBottomOffset
let metrics = BubbleMetrics.telegram()
for spec in Self.bubbleSpecs {
// Stack from bottom up, respecting bottom inset
var y = height - Self.initialBottomOffset - bottomInset
for spec in specs {
let bubbleWidth = floor(spec.widthFrac * width)
let bubbleHeight = spec.height
y -= bubbleHeight
guard y > -bubbleHeight else { break } // Off screen
guard y > -bubbleHeight else { break }
let bubbleFrame = CGRect(
x: Self.bubbleLeftInset,
y: y,
width: bubbleWidth,
height: bubbleHeight
)
// Position: incoming = left, outgoing = right
let bubbleX: CGFloat
if spec.outgoing {
bubbleX = width - bubbleWidth - Self.bubbleRightInset
} else if hasAvatar {
bubbleX = Self.bubbleLeftInsetWithAvatar
} else {
bubbleX = Self.bubbleLeftInsetNoAvatar
}
// Bubble shape layer (visible fill)
let bubbleFrame = CGRect(x: bubbleX, y: y, width: bubbleWidth, height: bubbleHeight)
// Use real stretchable bubble image (same as NativeMessageCell)
if let image = bubbleImages?.image(outgoing: spec.outgoing, mergeType: .none) {
let iv = UIImageView(image: image)
iv.frame = bubbleFrame
containerView.addSubview(iv)
bubbleImageViews.append(iv)
}
// Shimmer mask: use actual bubble path shape (not rect)
let bubblePath = BubbleGeometryEngine.makeBezierPath(
in: CGRect(origin: .zero, size: bubbleFrame.size),
mergeType: .none,
outgoing: false,
outgoing: spec.outgoing,
metrics: metrics
)
let bubbleLayer = CAShapeLayer()
bubbleLayer.frame = bubbleFrame
bubbleLayer.path = bubblePath.cgPath
bubbleLayer.fillColor = bubbleColor.cgColor
containerView.layer.addSublayer(bubbleLayer)
bubbleLayers.append(bubbleLayer)
// Add to combined mask for shimmer clipping
var translateTransform = CGAffineTransform(translationX: bubbleFrame.minX, y: bubbleFrame.minY)
if let translatedPath = bubblePath.cgPath.copy(using: &translateTransform) {
combinedMaskPath.addPath(translatedPath)
}
// Border mask (stroke only)
let borderStrokePath = bubblePath.cgPath.copy(
strokingWithWidth: 2,
lineCap: CGLineCap.round,
lineJoin: CGLineJoin.round,
miterLimit: 10
)
if let translatedBorderPath = borderStrokePath.copy(using: &translateTransform) {
combinedBorderMaskPath.addPath(translatedBorderPath)
var translate = CGAffineTransform(translationX: bubbleFrame.minX, y: bubbleFrame.minY)
if let translated = bubblePath.cgPath.copy(using: &translate) {
combinedMaskPath.addPath(translated)
}
// Avatar circle
let avatarFrame = CGRect(
x: Self.avatarLeftInset,
y: y + bubbleHeight - Self.avatarSize, // bottom-aligned with bubble
width: Self.avatarSize,
height: Self.avatarSize
)
let avatarLayer = CAShapeLayer()
avatarLayer.frame = avatarFrame
avatarLayer.path = UIBezierPath(ovalIn: CGRect(origin: .zero, size: avatarFrame.size)).cgPath
avatarLayer.fillColor = avatarColor.cgColor
avatarLayer.strokeColor = bubbleColor.cgColor
avatarLayer.lineWidth = 1
containerView.layer.addSublayer(avatarLayer)
avatarLayers.append(avatarLayer)
// Avatar (groups only, incoming only)
var currentAvatarFrame: CGRect?
if hasAvatar && !spec.outgoing {
let avFrame = CGRect(
x: Self.avatarLeftInset,
y: y + bubbleHeight - Self.avatarSize,
width: Self.avatarSize,
height: Self.avatarSize
)
let avatarView = UIView(frame: avFrame)
avatarView.backgroundColor = avatarColor
avatarView.layer.cornerRadius = Self.avatarSize / 2
containerView.addSubview(avatarView)
avatarViews.append(avatarView)
currentAvatarFrame = avFrame
// Add avatar to combined mask
let avatarCircle = CGPath(ellipseIn: avatarFrame, transform: nil)
combinedMaskPath.addPath(avatarCircle)
combinedMaskPath.addEllipse(in: avFrame)
}
bubbleInfos.append(BubbleInfo(frame: bubbleFrame, avatarFrame: currentAvatarFrame))
y -= Self.verticalGap
}
// Content shimmer layer with bubble mask
let contentShimmer = makeShimmerLayer(
size: bounds.size,
effectSize: Self.shimmerEffectSize,
color: UIColor.white.withAlphaComponent(Self.shimmerOpacity),
duration: Self.shimmerDuration
)
let contentMask = CAShapeLayer()
contentMask.path = combinedMaskPath
contentShimmer.mask = contentMask
containerView.layer.addSublayer(contentShimmer)
shimmerLayer = contentShimmer
// Shimmer overlay (screenBlendMode adds brightness on top of real bubble colors)
shimmerGradients.removeAll()
isShimmerRunning = false
// Border shimmer layer
let borderShimmer = makeShimmerLayer(
size: bounds.size,
effectSize: Self.borderShimmerEffectSize,
color: UIColor.white.withAlphaComponent(Self.borderShimmerOpacity),
duration: Self.shimmerDuration
)
let borderMask = CAShapeLayer()
borderMask.path = combinedBorderMaskPath
borderShimmer.mask = borderMask
containerView.layer.addSublayer(borderShimmer)
borderShimmerLayer = borderShimmer
let shimmer = makeShimmerLayer(size: bounds.size, effectSize: Self.shimmerEffectSize,
color: UIColor.white.withAlphaComponent(Self.shimmerOpacity))
let mask = CAShapeLayer()
mask.path = combinedMaskPath
shimmer.mask = mask
containerView.layer.addSublayer(shimmer)
shimmerLayer = shimmer
if window != nil {
startShimmerIfNeeded()
}
}
// MARK: - Shimmer Layer Factory
private func makeShimmerLayer(
size: CGSize,
effectSize: CGFloat,
color: UIColor,
duration: CFTimeInterval
) -> CALayer {
private func makeShimmerLayer(size: CGSize, effectSize: CGFloat, color: UIColor) -> CALayer {
let container = CALayer()
container.frame = CGRect(origin: .zero, size: size)
container.compositingFilter = "screenBlendMode"
// Gradient image (horizontal: transparent color transparent)
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [
color.withAlphaComponent(0).cgColor,
color.cgColor,
color.withAlphaComponent(0).cgColor,
]
gradientLayer.locations = [0.0, 0.5, 1.0]
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
gradientLayer.frame = CGRect(x: -effectSize, y: 0, width: effectSize, height: size.height)
container.addSublayer(gradientLayer)
// Animate position.x from -effectSize to size.width + effectSize
let animation = CABasicAnimation(keyPath: "position.x")
animation.fromValue = -effectSize / 2
animation.toValue = size.width + effectSize / 2
animation.duration = duration
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
animation.repeatCount = .infinity
gradientLayer.add(animation, forKey: "shimmer")
let gradient = CAGradientLayer()
gradient.colors = [color.withAlphaComponent(0).cgColor, color.cgColor, color.withAlphaComponent(0).cgColor]
gradient.locations = [0.0, 0.5, 1.0]
gradient.startPoint = CGPoint(x: 0, y: 0.5)
gradient.endPoint = CGPoint(x: 1, y: 0.5)
gradient.frame = CGRect(x: -effectSize, y: 0, width: effectSize, height: size.height)
container.addSublayer(gradient)
shimmerGradients.append(gradient)
return container
}
// MARK: - Staggered Fade Out
// MARK: - Shimmer Lifecycle
override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil { startShimmerIfNeeded() } else { stopShimmer() }
}
private func startShimmerIfNeeded() {
guard !isShimmerRunning, !shimmerGradients.isEmpty else { return }
isShimmerRunning = true
let width = bounds.width
guard width > 0 else { return }
for gradient in shimmerGradients {
let effectSize = gradient.bounds.width
let anim = CABasicAnimation(keyPath: "position.x")
anim.fromValue = -effectSize / 2
anim.toValue = width + effectSize / 2
anim.duration = Self.shimmerDuration
anim.timingFunction = CAMediaTimingFunction(name: .easeOut)
anim.repeatCount = .infinity
gradient.add(anim, forKey: "shimmer")
}
}
private func stopShimmer() {
guard isShimmerRunning else { return }
isShimmerRunning = false
for gradient in shimmerGradients { gradient.removeAnimation(forKey: "shimmer") }
}
// MARK: - Fade Out (fallback)
func animateOut(completion: @escaping () -> Void) {
let totalLayers = bubbleLayers.count + avatarLayers.count
guard totalLayers > 0 else {
completion()
return
}
// Fade out shimmer first (CALayer use CATransaction, not UIView.animate)
stopShimmer()
CATransaction.begin()
CATransaction.setAnimationDuration(0.15)
shimmerLayer?.opacity = 0
borderShimmerLayer?.opacity = 0
CATransaction.setCompletionBlock(completion)
layer.opacity = 0
CATransaction.commit()
}
// MARK: - AnimateOut With Cell Matching
func animateOutWithCells(
_ cells: [(cell: UIView, isOutgoing: Bool)],
heightNorm: CGFloat,
completion: @escaping () -> Void
) {
stopShimmer()
// Fade out skeleton
CATransaction.begin()
CATransaction.setAnimationDuration(0.15)
CATransaction.setCompletionBlock(completion)
layer.opacity = 0
CATransaction.commit()
// Staggered fade-out per bubble (bottom = index 0 fades first)
for (i, bubbleLayer) in bubbleLayers.enumerated() {
let delay = Double(i) * 0.02
let fade = CABasicAnimation(keyPath: "opacity")
fade.fromValue = 1.0
fade.toValue = 0.0
fade.duration = 0.2
fade.beginTime = CACurrentMediaTime() + delay
fade.fillMode = .forwards
fade.isRemovedOnCompletion = false
bubbleLayer.add(fade, forKey: "fadeOut")
}
// Per-cell entrance animation additive, on cell.layer
let now = CACurrentMediaTime()
for (i, avatarLayer) in avatarLayers.enumerated() {
let delay = Double(i) * 0.02
let fade = CABasicAnimation(keyPath: "opacity")
fade.fromValue = 1.0
fade.toValue = 0.0
fade.duration = 0.2
fade.beginTime = CACurrentMediaTime() + delay
fade.fillMode = .forwards
fade.isRemovedOnCompletion = false
avatarLayer.add(fade, forKey: "fadeOut")
}
for info in cells {
let cellLayer = info.cell.layer
let isIncoming = !info.isOutgoing
// Call completion after all animations complete
let totalDuration = Double(bubbleLayers.count) * 0.02 + 0.2
DispatchQueue.main.asyncAfter(deadline: .now() + totalDuration) {
completion()
let cellFrameInSelf = convert(info.cell.frame, from: info.cell.superview)
let delayFactor = max(0, cellFrameInSelf.minY / max(heightNorm, 1))
let delay = Double(delayFactor) * 0.1
// Alpha: 01
let fadeIn = CABasicAnimation(keyPath: "opacity")
fadeIn.fromValue = 0.0
fadeIn.toValue = 1.0
fadeIn.duration = 0.2
fadeIn.beginTime = now + delay
fadeIn.fillMode = .backwards
fadeIn.isRemovedOnCompletion = true
cellLayer.add(fadeIn, forKey: "skeletonFadeIn")
cellLayer.opacity = 1.0
// Position: additive spring
let xOffset: CGFloat = isIncoming ? 30 : -30
let posX = CASpringAnimation(keyPath: "position.x")
posX.fromValue = xOffset
posX.toValue = 0.0
posX.isAdditive = true
posX.stiffness = 555.0
posX.damping = 47.0
posX.mass = 1.0
posX.beginTime = now + delay
posX.fillMode = .backwards
posX.duration = posX.settlingDuration
cellLayer.add(posX, forKey: "skeletonPositionX")
let posY = CASpringAnimation(keyPath: "position.y")
posY.fromValue = 30.0
posY.toValue = 0.0
posY.isAdditive = true
posY.stiffness = 555.0
posY.damping = 47.0
posY.mass = 1.0
posY.beginTime = now + delay
posY.fillMode = .backwards
posY.duration = posY.settlingDuration
cellLayer.add(posY, forKey: "skeletonPositionY")
// Scale: additive
let scaleAnim = CABasicAnimation(keyPath: "transform")
scaleAnim.fromValue = CATransform3DMakeScale(0.85, 0.85, 1.0)
scaleAnim.toValue = CATransform3DIdentity
scaleAnim.isAdditive = true
scaleAnim.duration = 0.35
scaleAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
scaleAnim.beginTime = now + delay
scaleAnim.fillMode = .backwards
scaleAnim.isRemovedOnCompletion = true
cellLayer.add(scaleAnim, forKey: "skeletonScale")
}
}
}

View File

@@ -25,6 +25,9 @@ struct OpponentProfileView: View {
init(route: ChatRoute) {
self.route = route
_viewModel = StateObject(wrappedValue: PeerProfileViewModel(dialogKey: route.publicKey))
// Start expanded only if user has a real photo (not letter avatar)
let hasPhoto = AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) != nil
_isLargeHeader = State(initialValue: hasPhoto)
}
// MARK: - Computed properties

View File

@@ -148,7 +148,11 @@ final class ChatListRootViewController: UIViewController, UINavigationController
navigationController?.setNavigationBarHidden(true, animated: animated)
let blurProgress = searchHeaderView.isSearchActive ? 1.0 : (1.0 - lastSearchExpansion)
updateNavigationBarBlur(progress: blurProgress)
onDetailPresentedChanged?(navigationController?.viewControllers.count ?? 1 > 1)
// Don't update tab bar visibility during interactive swipe-back
// willShow delegate handles it with proper interactive transition checks.
if navigationController?.transitionCoordinator?.isInteractive != true {
onDetailPresentedChanged?(navigationController?.viewControllers.count ?? 1 > 1)
}
}
override func viewDidLayoutSubviews() {
@@ -207,6 +211,13 @@ final class ChatListRootViewController: UIViewController, UINavigationController
// Keep tab bar state in sync when leaving the screen while search is active.
if searchHeaderView.isSearchActive {
searchHeaderView.endSearch(animated: false, clearText: true)
// Restore toolbar immediately (no animation since we're leaving)
editButtonControl.alpha = 1.0
rightButtonsControl.alpha = 1.0
toolbarTitleView.alpha = 1.0
editButtonControl.isUserInteractionEnabled = true
rightButtonsControl.isUserInteractionEnabled = true
toolbarTitleView.isUserInteractionEnabled = true
}
}
@@ -290,6 +301,7 @@ final class ChatListRootViewController: UIViewController, UINavigationController
guard let self else { return }
self.applySearchExpansion(1.0, animated: true)
self.updateNavigationBarBlur(progress: active ? 1.0 : (1.0 - self.lastSearchExpansion))
self.animateToolbarForSearch(active: active)
self.onSearchActiveChanged?(active)
}
}
@@ -436,6 +448,27 @@ final class ChatListRootViewController: UIViewController, UINavigationController
renderList()
}
/// Telegram-style toolbar fade: 0.14s linear out, 0.3s linear in
private func animateToolbarForSearch(active: Bool) {
let targetAlpha: CGFloat = active ? 0.0 : 1.0
let duration: TimeInterval = active ? 0.14 : 0.3
UIView.animate(
withDuration: duration,
delay: 0,
options: [.curveLinear, .beginFromCurrentState],
animations: {
self.editButtonControl.alpha = targetAlpha
self.rightButtonsControl.alpha = targetAlpha
self.toolbarTitleView.alpha = targetAlpha
}
)
editButtonControl.isUserInteractionEnabled = !active
rightButtonsControl.isUserInteractionEnabled = !active
toolbarTitleView.isUserInteractionEnabled = !active
}
private func applySearchExpansion(_ expansion: CGFloat, animated: Bool) {
let clamped = max(0.0, min(1.0, expansion))
if searchHeaderView.isSearchActive && clamped < 0.999 {
@@ -465,9 +498,11 @@ final class ChatListRootViewController: UIViewController, UINavigationController
let updates = { self.view.layoutIfNeeded() }
if animated {
UIView.animate(
withDuration: 0.16,
withDuration: 0.5,
delay: 0,
options: [.curveEaseInOut, .beginFromCurrentState],
usingSpringWithDamping: 0.78,
initialSpringVelocity: 0,
options: [.beginFromCurrentState],
animations: updates
)
} else {
@@ -702,8 +737,24 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|| viewController is RequestChatsUIKitShellController
navigationController.setNavigationBarHidden(hideNavBar, animated: animated)
let isPresented = navigationController.viewControllers.count > 1
onDetailPresentedChanged?(isPresented)
if let coordinator = navigationController.transitionCoordinator, coordinator.isInteractive {
// Interactive swipe-back: defer tab bar show until transition COMPLETES.
// If cancelled (user swipes back but doesn't complete), don't show tab bar.
coordinator.notifyWhenInteractionChanges { [weak self, weak navigationController] context in
if context.isCancelled {
// Swipe cancelled stay on chat detail, keep tab bar hidden
return
}
// Swipe completed show tab bar after completion animation
guard let navigationController else { return }
let isPresented = navigationController.viewControllers.count > 1
self?.onDetailPresentedChanged?(isPresented)
}
} else {
// Non-interactive (programmatic push/pop): update immediately
let isPresented = navigationController.viewControllers.count > 1
onDetailPresentedChanged?(isPresented)
}
}
func navigationController(
@@ -711,6 +762,7 @@ final class ChatListRootViewController: UIViewController, UINavigationController
didShow viewController: UIViewController,
animated: Bool
) {
// Safety fallback: ensure state is correct after any transition
let isPresented = navigationController.viewControllers.count > 1
onDetailPresentedChanged?(isPresented)
}
@@ -1097,7 +1149,7 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
}
if animated {
UIView.animate(withDuration: 0.16, delay: 0, options: [.curveEaseInOut, .beginFromCurrentState], animations: updates)
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.78, initialSpringVelocity: 0, options: [.beginFromCurrentState], animations: updates)
} else {
updates()
}