From 8561fecbfc5b35e4b9ffa81eea9b82e4e5a27223 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Wed, 15 Apr 2026 14:51:07 +0500 Subject: [PATCH] =?UTF-8?q?UIKit=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20ChatDetailView=20+=20pinned=20header=20fraction?= =?UTF-8?q?=20fix=20+=20tab=20bar=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/RosettaTabBar.swift | 37 +- .../ChatDetail/ChatDetailViewController.swift | 96 +++- .../Chats/ChatDetail/NativeMessageCell.swift | 9 +- .../Chats/ChatDetail/NativeMessageList.swift | 69 ++- .../Chats/ChatDetail/NativeSkeletonView.swift | 473 +++++++++++------- .../ChatDetail/OpponentProfileView.swift | 3 + .../ChatList/UIKit/ChatListUIKitView.swift | 64 ++- Rosetta/Features/MainTabView.swift | 19 +- 8 files changed, 538 insertions(+), 232 deletions(-) diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift index d37d728..078d279 100644 --- a/Rosetta/DesignSystem/Components/RosettaTabBar.swift +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -75,6 +75,27 @@ final class RosettaTabBarUIView: UIView { } var onTabSelected: ((RosettaTab) -> Void)? 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 didDrag = false // true if .changed fired (not just a tap) @@ -389,11 +410,17 @@ extension RosettaTabBarUIView: UIGestureRecognizerDelegate { struct RosettaTabBarContainer: View { let selectedTab: RosettaTab var onTabSelected: ((RosettaTab) -> Void)? + var isVisible: Bool = true @State private var cachedBadgeText: String? private let barWidth: CGFloat = 90 * 3 + 8 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) .modifier(TabBarShadowModifier()) .padding(.bottom, 8) @@ -416,16 +443,22 @@ private struct RosettaTabBarBridge: UIViewRepresentable { let selectedTab: RosettaTab var onTabSelected: ((RosettaTab) -> Void)? var badgeText: String? + var isVisible: Bool = true func makeUIView(context: Context) -> RosettaTabBarUIView { let v = RosettaTabBarUIView(frame: .zero) 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) { let idx = selectedTab.interactionIndex if v.selectedIndex != idx { v.selectedIndex = idx } v.onTabSelected = onTabSelected; v.badgeText = badgeText + if v.isBarVisible != isVisible { + v.setBarVisible(isVisible, animated: true) + } } } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift index f53de12..d49e558 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift @@ -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 { diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index 70be037..3a514c4 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -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 diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 21d29c4..7429c0c 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -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() diff --git a/Rosetta/Features/Chats/ChatDetail/NativeSkeletonView.swift b/Rosetta/Features/Chats/ChatDetail/NativeSkeletonView.swift index 326c5c0..f4bef72 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeSkeletonView.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeSkeletonView.swift @@ -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") } } } diff --git a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift index ffe0e0e..25e0d02 100644 --- a/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift +++ b/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift @@ -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 diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift index 0773192..2aa72cb 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListUIKitView.swift @@ -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() } diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index a12fda4..4db697e 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -117,16 +117,15 @@ struct MainTabView: View { } .ignoresSafeArea() - if !isChatSearchActive && !isAnyChatDetailPresented && !isSettingsEditPresented && !isSettingsDetailPresented { - RosettaTabBarContainer( - selectedTab: selectedTab, - onTabSelected: { tab in - selectedTab = tab - } - ) - .ignoresSafeArea(.keyboard) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } + RosettaTabBarContainer( + selectedTab: selectedTab, + onTabSelected: { tab in + selectedTab = tab + }, + isVisible: !isChatSearchActive && !isAnyChatDetailPresented + && !isSettingsEditPresented && !isSettingsDetailPresented + ) + .ignoresSafeArea(.keyboard) } .ignoresSafeArea(.keyboard) .onChange(of: isChatSearchActive) { _, isActive in