UIKit миграция ChatDetailView + pinned header fraction fix + tab bar visibility
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 hidden→visible 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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 skeleton→real 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: 0→1
|
||||||
|
let fadeIn = CABasicAnimation(keyPath: "opacity")
|
||||||
|
fadeIn.fromValue = 0.0
|
||||||
|
fadeIn.toValue = 1.0
|
||||||
|
fadeIn.duration = 0.2
|
||||||
|
fadeIn.beginTime = now + delay
|
||||||
|
fadeIn.fillMode = .backwards
|
||||||
|
fadeIn.isRemovedOnCompletion = true
|
||||||
|
cellLayer.add(fadeIn, forKey: "skeletonFadeIn")
|
||||||
|
cellLayer.opacity = 1.0
|
||||||
|
|
||||||
|
// Position: additive spring
|
||||||
|
let xOffset: CGFloat = isIncoming ? 30 : -30
|
||||||
|
|
||||||
|
let posX = CASpringAnimation(keyPath: "position.x")
|
||||||
|
posX.fromValue = xOffset
|
||||||
|
posX.toValue = 0.0
|
||||||
|
posX.isAdditive = true
|
||||||
|
posX.stiffness = 555.0
|
||||||
|
posX.damping = 47.0
|
||||||
|
posX.mass = 1.0
|
||||||
|
posX.beginTime = now + delay
|
||||||
|
posX.fillMode = .backwards
|
||||||
|
posX.duration = posX.settlingDuration
|
||||||
|
cellLayer.add(posX, forKey: "skeletonPositionX")
|
||||||
|
|
||||||
|
let posY = CASpringAnimation(keyPath: "position.y")
|
||||||
|
posY.fromValue = 30.0
|
||||||
|
posY.toValue = 0.0
|
||||||
|
posY.isAdditive = true
|
||||||
|
posY.stiffness = 555.0
|
||||||
|
posY.damping = 47.0
|
||||||
|
posY.mass = 1.0
|
||||||
|
posY.beginTime = now + delay
|
||||||
|
posY.fillMode = .backwards
|
||||||
|
posY.duration = posY.settlingDuration
|
||||||
|
cellLayer.add(posY, forKey: "skeletonPositionY")
|
||||||
|
|
||||||
|
// Scale: additive
|
||||||
|
let scaleAnim = CABasicAnimation(keyPath: "transform")
|
||||||
|
scaleAnim.fromValue = CATransform3DMakeScale(0.85, 0.85, 1.0)
|
||||||
|
scaleAnim.toValue = CATransform3DIdentity
|
||||||
|
scaleAnim.isAdditive = true
|
||||||
|
scaleAnim.duration = 0.35
|
||||||
|
scaleAnim.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||||
|
scaleAnim.beginTime = now + delay
|
||||||
|
scaleAnim.fillMode = .backwards
|
||||||
|
scaleAnim.isRemovedOnCompletion = true
|
||||||
|
cellLayer.add(scaleAnim, forKey: "skeletonScale")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ struct OpponentProfileView: View {
|
|||||||
init(route: ChatRoute) {
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user