UIKit миграция ChatDetailView + pinned header fraction fix + tab bar visibility
This commit is contained in:
@@ -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 hidden→visible 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 skeleton→real 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: 0→1
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user