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

@@ -75,6 +75,27 @@ final class RosettaTabBarUIView: UIView {
} }
var onTabSelected: ((RosettaTab) -> Void)? var onTabSelected: ((RosettaTab) -> Void)?
var badgeText: String? { didSet { layoutBadge() } } var badgeText: String? { didSet { layoutBadge() } }
private(set) var isBarVisible: Bool = true
func setBarVisible(_ visible: Bool, animated: Bool) {
guard isBarVisible != visible else { return }
isBarVisible = visible
isUserInteractionEnabled = visible
if visible { isHidden = false } // unhide before animation
let doIt = {
self.alpha = visible ? 1.0 : 0.0
self.transform = visible ? .identity : CGAffineTransform(translationX: 0, y: 20)
}
let completion = { (_: Bool) in
if !visible { self.isHidden = true } // hide after animation
}
if animated {
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.78, initialSpringVelocity: 0, options: [.beginFromCurrentState], animations: doIt, completion: completion)
} else {
doIt()
completion(true)
}
}
private var isDragging = false private var isDragging = false
private var didDrag = false // true if .changed fired (not just a tap) private var didDrag = false // true if .changed fired (not just a tap)
@@ -389,11 +410,17 @@ extension RosettaTabBarUIView: UIGestureRecognizerDelegate {
struct RosettaTabBarContainer: View { struct RosettaTabBarContainer: View {
let selectedTab: RosettaTab let selectedTab: RosettaTab
var onTabSelected: ((RosettaTab) -> Void)? var onTabSelected: ((RosettaTab) -> Void)?
var isVisible: Bool = true
@State private var cachedBadgeText: String? @State private var cachedBadgeText: String?
private let barWidth: CGFloat = 90 * 3 + 8 private let barWidth: CGFloat = 90 * 3 + 8
var body: some View { var body: some View {
RosettaTabBarBridge(selectedTab: selectedTab, onTabSelected: onTabSelected, badgeText: cachedBadgeText) RosettaTabBarBridge(
selectedTab: selectedTab,
onTabSelected: onTabSelected,
badgeText: cachedBadgeText,
isVisible: isVisible
)
.frame(width: barWidth, height: 64) .frame(width: barWidth, height: 64)
.modifier(TabBarShadowModifier()) .modifier(TabBarShadowModifier())
.padding(.bottom, 8) .padding(.bottom, 8)
@@ -416,16 +443,22 @@ private struct RosettaTabBarBridge: UIViewRepresentable {
let selectedTab: RosettaTab let selectedTab: RosettaTab
var onTabSelected: ((RosettaTab) -> Void)? var onTabSelected: ((RosettaTab) -> Void)?
var badgeText: String? var badgeText: String?
var isVisible: Bool = true
func makeUIView(context: Context) -> RosettaTabBarUIView { func makeUIView(context: Context) -> RosettaTabBarUIView {
let v = RosettaTabBarUIView(frame: .zero) let v = RosettaTabBarUIView(frame: .zero)
v.selectedIndex = selectedTab.interactionIndex v.selectedIndex = selectedTab.interactionIndex
v.onTabSelected = onTabSelected; v.badgeText = badgeText; return v v.onTabSelected = onTabSelected; v.badgeText = badgeText
v.setBarVisible(isVisible, animated: false)
return v
} }
func updateUIView(_ v: RosettaTabBarUIView, context: Context) { func updateUIView(_ v: RosettaTabBarUIView, context: Context) {
let idx = selectedTab.interactionIndex let idx = selectedTab.interactionIndex
if v.selectedIndex != idx { v.selectedIndex = idx } if v.selectedIndex != idx { v.selectedIndex = idx }
v.onTabSelected = onTabSelected; v.badgeText = badgeText v.onTabSelected = onTabSelected; v.badgeText = badgeText
if v.isBarVisible != isVisible {
v.setBarVisible(isVisible, animated: true)
}
} }
} }

View File

@@ -216,18 +216,19 @@ final class ChatDetailViewController: UIViewController {
let safeTop = view.safeAreaInsets.top let safeTop = view.safeAreaInsets.top
let centerY = safeTop + headerBarHeight * 0.5 let centerY = safeTop + headerBarHeight * 0.5
// Back button (left) // Back button (left) 8pt from edge
let sideMargin: CGFloat = 8
let backSize = backButton.intrinsicContentSize let backSize = backButton.intrinsicContentSize
backButton.frame = CGRect( backButton.frame = CGRect(
x: 6, x: sideMargin,
y: centerY - backSize.height * 0.5, y: centerY - backSize.height * 0.5,
width: backSize.width, width: backSize.width,
height: backSize.height 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 avatarOuterSize: CGFloat = 44
let avatarRight: CGFloat = 16 let avatarRight: CGFloat = sideMargin
avatarButton.frame = CGRect( avatarButton.frame = CGRect(
x: view.bounds.width - avatarRight - avatarOuterSize, x: view.bounds.width - avatarRight - avatarOuterSize,
y: centerY - avatarOuterSize * 0.5, y: centerY - avatarOuterSize * 0.5,
@@ -235,15 +236,24 @@ final class ChatDetailViewController: UIViewController {
height: avatarOuterSize height: avatarOuterSize
) )
// Title pill (center, between back and avatar) // Title pill (content-sized, centered between back and avatar)
let titleLeft = backButton.frame.maxX + 8
let titleRight = avatarButton.frame.minX - 8
let titleWidth = titleRight - titleLeft
let titleHeight: CGFloat = 44 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( titlePill.frame = CGRect(
x: titleLeft, x: pillX,
y: centerY - titleHeight * 0.5, y: centerY - titleHeight * 0.5,
width: titleWidth, width: clampedWidth,
height: titleHeight height: titleHeight
) )
} }
@@ -532,13 +542,18 @@ final class ChatDetailViewController: UIViewController {
private func openProfile() { private func openProfile() {
view.endEditing(true) 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 { if route.isGroup {
let groupInfo = GroupInfoView(groupDialogKey: route.publicKey) let groupInfo = GroupInfoView(groupDialogKey: route.publicKey)
let hosting = UIHostingController(rootView: groupInfo) let hosting = UIHostingController(rootView: groupInfo)
hosting.navigationItem.hidesBackButton = true // prevent system "< Back" flash
navigationController?.pushViewController(hosting, animated: true) navigationController?.pushViewController(hosting, animated: true)
} else if !route.isSystemAccount { } else if !route.isSystemAccount {
let profile = OpponentProfileView(route: route) let profile = OpponentProfileView(route: route)
let hosting = UIHostingController(rootView: profile) let hosting = UIHostingController(rootView: profile)
hosting.navigationItem.hidesBackButton = true // prevent system "< Back" flash
navigationController?.pushViewController(hosting, animated: true) navigationController?.pushViewController(hosting, animated: true)
} }
} }
@@ -568,8 +583,10 @@ final class ChatDetailViewController: UIViewController {
} else { } else {
profileRoute = ChatRoute(publicKey: senderKey, title: String(senderKey.prefix(8)), username: "", verified: 0) profileRoute = ChatRoute(publicKey: senderKey, title: String(senderKey.prefix(8)), username: "", verified: 0)
} }
navigationController?.setNavigationBarHidden(false, animated: false)
let profile = OpponentProfileView(route: profileRoute) let profile = OpponentProfileView(route: profileRoute)
let hosting = UIHostingController(rootView: profile) let hosting = UIHostingController(rootView: profile)
hosting.navigationItem.hidesBackButton = true
navigationController?.pushViewController(hosting, animated: true) navigationController?.pushViewController(hosting, animated: true)
} }
@@ -1152,6 +1169,13 @@ private final class ChatDetailTitlePill: UIControl {
subtitleLabel.textColor = subtitleColor 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() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
glassView.frame = bounds glassView.frame = bounds
@@ -1186,6 +1210,7 @@ private final class ChatDetailTitlePill: UIControl {
private final class ChatDetailAvatarButton: UIControl { private final class ChatDetailAvatarButton: UIControl {
private let glassView = TelegramGlassUIView(frame: .zero) private let glassView = TelegramGlassUIView(frame: .zero)
private let avatarBackgroundView = UIView()
private let avatarImageView = UIImageView() private let avatarImageView = UIImageView()
private let initialsLabel = UILabel() private let initialsLabel = UILabel()
private let route: ChatRoute private let route: ChatRoute
@@ -1196,7 +1221,6 @@ private final class ChatDetailAvatarButton: UIControl {
setupUI() setupUI()
updateAvatar() updateAvatar()
// Observe avatar changes
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, selector: #selector(avatarChanged), self, selector: #selector(avatarChanged),
name: .init("AvatarRepositoryDidChange"), object: nil name: .init("AvatarRepositoryDidChange"), object: nil
@@ -1207,13 +1231,20 @@ private final class ChatDetailAvatarButton: UIControl {
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
private func setupUI() { private func setupUI() {
// All subviews must NOT intercept touches UIControl handles them
glassView.isUserInteractionEnabled = false glassView.isUserInteractionEnabled = false
addSubview(glassView) addSubview(glassView)
avatarBackgroundView.isUserInteractionEnabled = false
avatarBackgroundView.clipsToBounds = true
addSubview(avatarBackgroundView)
avatarImageView.isUserInteractionEnabled = false
avatarImageView.contentMode = .scaleAspectFill avatarImageView.contentMode = .scaleAspectFill
avatarImageView.clipsToBounds = true avatarImageView.clipsToBounds = true
addSubview(avatarImageView) addSubview(avatarImageView)
initialsLabel.isUserInteractionEnabled = false
initialsLabel.font = .systemFont(ofSize: 16, weight: .medium) initialsLabel.font = .systemFont(ofSize: 16, weight: .medium)
initialsLabel.textColor = .white initialsLabel.textColor = .white
initialsLabel.textAlignment = .center initialsLabel.textAlignment = .center
@@ -1238,8 +1269,19 @@ private final class ChatDetailAvatarButton: UIControl {
: route.isGroup ? RosettaColors.groupInitial(name: title, publicKey: route.publicKey) : route.isGroup ? RosettaColors.groupInitial(name: title, publicKey: route.publicKey)
: RosettaColors.initials(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) 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 pad = (bounds.width - avatarDiam) / 2
let avatarFrame = CGRect(x: pad, y: pad, width: avatarDiam, height: avatarDiam) let avatarFrame = CGRect(x: pad, y: pad, width: avatarDiam, height: avatarDiam)
avatarBackgroundView.frame = avatarFrame
avatarBackgroundView.layer.cornerRadius = avatarDiam * 0.5
avatarImageView.frame = avatarFrame avatarImageView.frame = avatarFrame
avatarImageView.layer.cornerRadius = avatarDiam * 0.5 avatarImageView.layer.cornerRadius = avatarDiam * 0.5
initialsLabel.frame = avatarFrame initialsLabel.frame = avatarFrame
layer.cornerRadius = bounds.height * 0.5
clipsToBounds = true
} }
override var isHighlighted: Bool { 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) // MARK: - UIGestureRecognizerDelegate (Full-Width Swipe Back)
extension ChatDetailViewController: UIGestureRecognizerDelegate { 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). // 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). // `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, outgoingColor: outgoingColor,
incomingColor: incomingColor incomingColor: incomingColor
) )
private static var bubbleImagesStyle: UIUserInterfaceStyle = .unspecified 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. /// Regenerate cached bubble images after theme change.
/// Must be called on main thread. `performAsCurrent` ensures dynamic /// Must be called on main thread. `performAsCurrent` ensures dynamic
/// `incomingColor` resolves with the correct light/dark traits. /// `incomingColor` resolves with the correct light/dark traits.
@@ -2936,6 +2939,10 @@ final class NativeMessageCell: UICollectionViewCell {
layer.removeAnimation(forKey: "insertionSlide") layer.removeAnimation(forKey: "insertionSlide")
layer.removeAnimation(forKey: "insertionMove") layer.removeAnimation(forKey: "insertionMove")
contentView.layer.removeAnimation(forKey: "insertionAlpha") contentView.layer.removeAnimation(forKey: "insertionAlpha")
layer.removeAnimation(forKey: "skeletonScale")
layer.removeAnimation(forKey: "skeletonPositionX")
layer.removeAnimation(forKey: "skeletonPositionY")
layer.removeAnimation(forKey: "skeletonFadeIn")
dateHeaderContainer.isHidden = true dateHeaderContainer.isHidden = true
dateHeaderLabel.text = nil dateHeaderLabel.text = nil
isInlineDateHeaderHidden = false isInlineDateHeaderHidden = false

View File

@@ -270,23 +270,65 @@ final class NativeMessageListController: UIViewController {
private func showSkeleton() { private func showSkeleton() {
guard skeletonView == nil else { return } guard skeletonView == nil else { return }
let skeleton = NativeSkeletonView() let skeleton = NativeSkeletonView()
skeleton.chatType = config.isGroupChat ? .group : .user
skeleton.clipsToBounds = true
skeleton.frame = view.bounds skeleton.frame = view.bounds
skeleton.autoresizingMask = [.flexibleWidth, .flexibleHeight] skeleton.autoresizingMask = [.flexibleWidth, .flexibleHeight]
// Insert below composer so it never overlaps the input bar
if let composer = composerView {
view.insertSubview(skeleton, belowSubview: composer)
} else {
view.addSubview(skeleton) view.addSubview(skeleton)
}
skeletonView = skeleton skeletonView = skeleton
isShowingSkeleton = true isShowingSkeleton = true
} }
private func hideSkeletonAnimated() { /// Update skeleton bottom inset after layout (safe area + composer known).
private func updateSkeletonInset() {
guard let skeleton = skeletonView else { return } 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 isShowingSkeleton = false
guard let skeleton = skeletonView else { return }
skeletonView = nil skeletonView = nil
// 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))
}
let heightNorm = collectionView.bounds.height - collectionView.contentInset.top
if cellInfos.isEmpty {
// Fallback: no visible cells simple fade
skeleton.animateOut { skeleton.animateOut {
skeleton.removeFromSuperview() skeleton.removeFromSuperview()
} }
// Fallback: force remove after 1s if animation didn't complete } else {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak skeleton] in 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() skeleton?.removeFromSuperview()
} }
} }
@@ -330,6 +372,9 @@ final class NativeMessageListController: UIViewController {
// Date pills sit between collection view and composer (z-order). // Date pills sit between collection view and composer (z-order).
// Composer covers pills naturally no bringSubviewToFront needed. // Composer covers pills naturally no bringSubviewToFront needed.
// Update skeleton inset now that safe area and composer height are known.
updateSkeletonInset()
} }
override func viewSafeAreaInsetsDidChange() { override func viewSafeAreaInsetsDidChange() {
@@ -1061,10 +1106,8 @@ final class NativeMessageListController: UIViewController {
/// Called from SwiftUI when messages array changes. /// Called from SwiftUI when messages array changes.
func update(messages: [ChatMessage], animated: Bool = false) { func update(messages: [ChatMessage], animated: Bool = false) {
// Hide skeleton on first message arrival // Defer skeleton dismiss until after snapshot is applied (cells must exist for fly-in animation)
if isShowingSkeleton && !messages.isEmpty { let shouldDismissSkeleton = isShowingSkeleton && !messages.isEmpty
hideSkeletonAnimated()
}
let oldIds = Set(self.messages.map(\.id)) let oldIds = Set(self.messages.map(\.id))
let oldNewestId = self.messages.last?.id let oldNewestId = self.messages.last?.id
@@ -1150,6 +1193,12 @@ final class NativeMessageListController: UIViewController {
dataSource.apply(snapshot, animatingDifferences: false) 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 // Apply Telegram-style insertion animations after layout settles
if isInteractive { if isInteractive {
collectionView.layoutIfNeeded() collectionView.layoutIfNeeded()

View File

@@ -2,66 +2,120 @@ import UIKit
// MARK: - NativeSkeletonView // MARK: - NativeSkeletonView
/// Telegram-quality skeleton loading for chat message list. /// Skeleton loading for chat message list.
/// Shows 14 incoming bubble placeholders stacked from bottom up with shimmer animation. /// Shows placeholder bubbles that look like real messages (same colors, tails, stretchable images)
/// Telegram parity: ChatLoadingNode.swift 14 bubbles, shimmer via screenBlendMode. /// with a shimmer overlay. Mix of incoming/outgoing for 1-on-1, all incoming for groups.
final class NativeSkeletonView: UIView { final class NativeSkeletonView: UIView {
// MARK: - Telegram-Exact Dimensions // MARK: - Chat Type
private static let shortHeight: CGFloat = 71 enum ChatType { case user, group }
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
/// Telegram-exact width fractions and heights for 14 skeleton bubbles. var chatType: ChatType = .group {
private static let bubbleSpecs: [(widthFrac: CGFloat, height: CGFloat)] = [ didSet {
(0.47, tallHeight), (0.58, tallHeight), (0.69, tallHeight), (0.47, tallHeight), guard chatType != oldValue else { return }
(0.58, shortHeight), (0.36, tallHeight), (0.47, tallHeight), (0.36, shortHeight), setNeedsLayout()
(0.58, tallHeight), (0.69, tallHeight), (0.58, tallHeight), (0.36, shortHeight), }
(0.47, tallHeight), (0.58, tallHeight), }
/// 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 shimmerDuration: CFTimeInterval = 1.6
private static let shimmerEffectSize: CGFloat = 280 private static let shimmerEffectSize: CGFloat = 280
private static let shimmerOpacity: CGFloat = 0.14 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 // MARK: - Subviews
private let containerView = UIView() private let containerView = UIView()
private var bubbleLayers: [CAShapeLayer] = [] private var bubbleImageViews: [UIImageView] = []
private var avatarLayers: [CAShapeLayer] = [] private var avatarViews: [UIView] = []
private var shimmerLayer: CALayer? private var shimmerGradients: [CAGradientLayer] = []
private var borderShimmerLayer: CALayer? private var isShimmerRunning = false
// MARK: - Init // MARK: - Init
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
setup()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Setup
private func setup() {
backgroundColor = .clear backgroundColor = .clear
isUserInteractionEnabled = false isUserInteractionEnabled = false
containerView.frame = bounds containerView.frame = bounds
containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight] containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(containerView) addSubview(containerView)
} }
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Layout // MARK: - Layout
override func layoutSubviews() { override func layoutSubviews() {
@@ -71,213 +125,254 @@ final class NativeSkeletonView: UIView {
} }
private func rebuildBubbles() { private func rebuildBubbles() {
// Remove old layers // Clean up
bubbleLayers.forEach { $0.removeFromSuperlayer() } bubbleImageViews.forEach { $0.removeFromSuperview() }
avatarLayers.forEach { $0.removeFromSuperlayer() } avatarViews.forEach { $0.removeFromSuperview() }
bubbleLayers.removeAll() bubbleImageViews.removeAll()
avatarLayers.removeAll() avatarViews.removeAll()
bubbleInfos.removeAll()
shimmerLayer?.removeFromSuperlayer() shimmerLayer?.removeFromSuperlayer()
borderShimmerLayer?.removeFromSuperlayer()
let width = bounds.width let width = bounds.width
let height = bounds.height let height = bounds.height
guard width > 0, height > 0 else { return } 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 themeMode = UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "dark"
let isDark = themeMode != "light" let isDark = themeMode != "light"
let bubbleColor = isDark
? UIColor.gray.withAlphaComponent(0.08)
: UIColor.gray.withAlphaComponent(0.10)
let avatarColor = isDark let avatarColor = isDark
? UIColor.gray.withAlphaComponent(0.06) ? UIColor.gray.withAlphaComponent(0.12)
: UIColor.gray.withAlphaComponent(0.08) : UIColor.gray.withAlphaComponent(0.15)
// Build mask from all bubbles combined // Combined mask for shimmer overlay (bubble-shaped, not rectangular)
let combinedMaskPath = CGMutablePath() let combinedMaskPath = CGMutablePath()
let combinedBorderMaskPath = CGMutablePath()
// Stack from bottom up
var y = height - Self.initialBottomOffset
let metrics = BubbleMetrics.telegram() 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 bubbleWidth = floor(spec.widthFrac * width)
let bubbleHeight = spec.height let bubbleHeight = spec.height
y -= bubbleHeight y -= bubbleHeight
guard y > -bubbleHeight else { break } // Off screen guard y > -bubbleHeight else { break }
let bubbleFrame = CGRect( // Position: incoming = left, outgoing = right
x: Self.bubbleLeftInset, let bubbleX: CGFloat
y: y, if spec.outgoing {
width: bubbleWidth, bubbleX = width - bubbleWidth - Self.bubbleRightInset
height: bubbleHeight } 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( let bubblePath = BubbleGeometryEngine.makeBezierPath(
in: CGRect(origin: .zero, size: bubbleFrame.size), in: CGRect(origin: .zero, size: bubbleFrame.size),
mergeType: .none, mergeType: .none,
outgoing: false, outgoing: spec.outgoing,
metrics: metrics metrics: metrics
) )
var translate = CGAffineTransform(translationX: bubbleFrame.minX, y: bubbleFrame.minY)
let bubbleLayer = CAShapeLayer() if let translated = bubblePath.cgPath.copy(using: &translate) {
bubbleLayer.frame = bubbleFrame combinedMaskPath.addPath(translated)
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)
} }
// Avatar circle // Avatar (groups only, incoming only)
let avatarFrame = CGRect( var currentAvatarFrame: CGRect?
if hasAvatar && !spec.outgoing {
let avFrame = CGRect(
x: Self.avatarLeftInset, x: Self.avatarLeftInset,
y: y + bubbleHeight - Self.avatarSize, // bottom-aligned with bubble y: y + bubbleHeight - Self.avatarSize,
width: Self.avatarSize, width: Self.avatarSize,
height: Self.avatarSize height: Self.avatarSize
) )
let avatarLayer = CAShapeLayer() let avatarView = UIView(frame: avFrame)
avatarLayer.frame = avatarFrame avatarView.backgroundColor = avatarColor
avatarLayer.path = UIBezierPath(ovalIn: CGRect(origin: .zero, size: avatarFrame.size)).cgPath avatarView.layer.cornerRadius = Self.avatarSize / 2
avatarLayer.fillColor = avatarColor.cgColor containerView.addSubview(avatarView)
avatarLayer.strokeColor = bubbleColor.cgColor avatarViews.append(avatarView)
avatarLayer.lineWidth = 1 currentAvatarFrame = avFrame
containerView.layer.addSublayer(avatarLayer)
avatarLayers.append(avatarLayer)
// Add avatar to combined mask combinedMaskPath.addEllipse(in: avFrame)
let avatarCircle = CGPath(ellipseIn: avatarFrame, transform: nil) }
combinedMaskPath.addPath(avatarCircle)
bubbleInfos.append(BubbleInfo(frame: bubbleFrame, avatarFrame: currentAvatarFrame))
y -= Self.verticalGap y -= Self.verticalGap
} }
// Content shimmer layer with bubble mask // Shimmer overlay (screenBlendMode adds brightness on top of real bubble colors)
let contentShimmer = makeShimmerLayer( shimmerGradients.removeAll()
size: bounds.size, isShimmerRunning = false
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
// Border shimmer layer let shimmer = makeShimmerLayer(size: bounds.size, effectSize: Self.shimmerEffectSize,
let borderShimmer = makeShimmerLayer( color: UIColor.white.withAlphaComponent(Self.shimmerOpacity))
size: bounds.size, let mask = CAShapeLayer()
effectSize: Self.borderShimmerEffectSize, mask.path = combinedMaskPath
color: UIColor.white.withAlphaComponent(Self.borderShimmerOpacity), shimmer.mask = mask
duration: Self.shimmerDuration containerView.layer.addSublayer(shimmer)
) shimmerLayer = shimmer
let borderMask = CAShapeLayer()
borderMask.path = combinedBorderMaskPath if window != nil {
borderShimmer.mask = borderMask startShimmerIfNeeded()
containerView.layer.addSublayer(borderShimmer) }
borderShimmerLayer = borderShimmer
} }
// MARK: - Shimmer Layer Factory // MARK: - Shimmer Layer Factory
private func makeShimmerLayer( private func makeShimmerLayer(size: CGSize, effectSize: CGFloat, color: UIColor) -> CALayer {
size: CGSize,
effectSize: CGFloat,
color: UIColor,
duration: CFTimeInterval
) -> CALayer {
let container = CALayer() let container = CALayer()
container.frame = CGRect(origin: .zero, size: size) container.frame = CGRect(origin: .zero, size: size)
container.compositingFilter = "screenBlendMode" container.compositingFilter = "screenBlendMode"
// Gradient image (horizontal: transparent color transparent) let gradient = CAGradientLayer()
let gradientLayer = CAGradientLayer() gradient.colors = [color.withAlphaComponent(0).cgColor, color.cgColor, color.withAlphaComponent(0).cgColor]
gradientLayer.colors = [ gradient.locations = [0.0, 0.5, 1.0]
color.withAlphaComponent(0).cgColor, gradient.startPoint = CGPoint(x: 0, y: 0.5)
color.cgColor, gradient.endPoint = CGPoint(x: 1, y: 0.5)
color.withAlphaComponent(0).cgColor, gradient.frame = CGRect(x: -effectSize, y: 0, width: effectSize, height: size.height)
]
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")
container.addSublayer(gradient)
shimmerGradients.append(gradient)
return container 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) { func animateOut(completion: @escaping () -> Void) {
let totalLayers = bubbleLayers.count + avatarLayers.count stopShimmer()
guard totalLayers > 0 else {
completion()
return
}
// Fade out shimmer first (CALayer use CATransaction, not UIView.animate)
CATransaction.begin() CATransaction.begin()
CATransaction.setAnimationDuration(0.15) CATransaction.setAnimationDuration(0.15)
shimmerLayer?.opacity = 0 CATransaction.setCompletionBlock(completion)
borderShimmerLayer?.opacity = 0 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() CATransaction.commit()
// Staggered fade-out per bubble (bottom = index 0 fades first) // Per-cell entrance animation additive, on cell.layer
for (i, bubbleLayer) in bubbleLayers.enumerated() { let now = CACurrentMediaTime()
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")
}
for (i, avatarLayer) in avatarLayers.enumerated() { for info in cells {
let delay = Double(i) * 0.02 let cellLayer = info.cell.layer
let fade = CABasicAnimation(keyPath: "opacity") let isIncoming = !info.isOutgoing
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")
}
// Call completion after all animations complete let cellFrameInSelf = convert(info.cell.frame, from: info.cell.superview)
let totalDuration = Double(bubbleLayers.count) * 0.02 + 0.2 let delayFactor = max(0, cellFrameInSelf.minY / max(heightNorm, 1))
DispatchQueue.main.asyncAfter(deadline: .now() + totalDuration) { let delay = Double(delayFactor) * 0.1
completion()
// 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) { init(route: ChatRoute) {
self.route = route self.route = route
_viewModel = StateObject(wrappedValue: PeerProfileViewModel(dialogKey: route.publicKey)) _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 // MARK: - Computed properties

View File

@@ -148,8 +148,12 @@ final class ChatListRootViewController: UIViewController, UINavigationController
navigationController?.setNavigationBarHidden(true, animated: animated) navigationController?.setNavigationBarHidden(true, animated: animated)
let blurProgress = searchHeaderView.isSearchActive ? 1.0 : (1.0 - lastSearchExpansion) let blurProgress = searchHeaderView.isSearchActive ? 1.0 : (1.0 - lastSearchExpansion)
updateNavigationBarBlur(progress: blurProgress) updateNavigationBarBlur(progress: blurProgress)
// 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) onDetailPresentedChanged?(navigationController?.viewControllers.count ?? 1 > 1)
} }
}
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
@@ -207,6 +211,13 @@ final class ChatListRootViewController: UIViewController, UINavigationController
// Keep tab bar state in sync when leaving the screen while search is active. // Keep tab bar state in sync when leaving the screen while search is active.
if searchHeaderView.isSearchActive { if searchHeaderView.isSearchActive {
searchHeaderView.endSearch(animated: false, clearText: true) 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 } guard let self else { return }
self.applySearchExpansion(1.0, animated: true) self.applySearchExpansion(1.0, animated: true)
self.updateNavigationBarBlur(progress: active ? 1.0 : (1.0 - self.lastSearchExpansion)) self.updateNavigationBarBlur(progress: active ? 1.0 : (1.0 - self.lastSearchExpansion))
self.animateToolbarForSearch(active: active)
self.onSearchActiveChanged?(active) self.onSearchActiveChanged?(active)
} }
} }
@@ -436,6 +448,27 @@ final class ChatListRootViewController: UIViewController, UINavigationController
renderList() 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) { private func applySearchExpansion(_ expansion: CGFloat, animated: Bool) {
let clamped = max(0.0, min(1.0, expansion)) let clamped = max(0.0, min(1.0, expansion))
if searchHeaderView.isSearchActive && clamped < 0.999 { if searchHeaderView.isSearchActive && clamped < 0.999 {
@@ -465,9 +498,11 @@ final class ChatListRootViewController: UIViewController, UINavigationController
let updates = { self.view.layoutIfNeeded() } let updates = { self.view.layoutIfNeeded() }
if animated { if animated {
UIView.animate( UIView.animate(
withDuration: 0.16, withDuration: 0.5,
delay: 0, delay: 0,
options: [.curveEaseInOut, .beginFromCurrentState], usingSpringWithDamping: 0.78,
initialSpringVelocity: 0,
options: [.beginFromCurrentState],
animations: updates animations: updates
) )
} else { } else {
@@ -702,15 +737,32 @@ final class ChatListRootViewController: UIViewController, UINavigationController
|| viewController is RequestChatsUIKitShellController || viewController is RequestChatsUIKitShellController
navigationController.setNavigationBarHidden(hideNavBar, animated: animated) navigationController.setNavigationBarHidden(hideNavBar, animated: animated)
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 let isPresented = navigationController.viewControllers.count > 1
onDetailPresentedChanged?(isPresented) onDetailPresentedChanged?(isPresented)
} }
}
func navigationController( func navigationController(
_ navigationController: UINavigationController, _ navigationController: UINavigationController,
didShow viewController: UIViewController, didShow viewController: UIViewController,
animated: Bool animated: Bool
) { ) {
// Safety fallback: ensure state is correct after any transition
let isPresented = navigationController.viewControllers.count > 1 let isPresented = navigationController.viewControllers.count > 1
onDetailPresentedChanged?(isPresented) onDetailPresentedChanged?(isPresented)
} }
@@ -1097,7 +1149,7 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
} }
if animated { 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 { } else {
updates() updates()
} }

View File

@@ -117,16 +117,15 @@ struct MainTabView: View {
} }
.ignoresSafeArea() .ignoresSafeArea()
if !isChatSearchActive && !isAnyChatDetailPresented && !isSettingsEditPresented && !isSettingsDetailPresented {
RosettaTabBarContainer( RosettaTabBarContainer(
selectedTab: selectedTab, selectedTab: selectedTab,
onTabSelected: { tab in onTabSelected: { tab in
selectedTab = tab selectedTab = tab
} },
isVisible: !isChatSearchActive && !isAnyChatDetailPresented
&& !isSettingsEditPresented && !isSettingsDetailPresented
) )
.ignoresSafeArea(.keyboard) .ignoresSafeArea(.keyboard)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
} }
.ignoresSafeArea(.keyboard) .ignoresSafeArea(.keyboard)
.onChange(of: isChatSearchActive) { _, isActive in .onChange(of: isChatSearchActive) { _, isActive in