Исправление winding direction хвостика incoming-баблов + выравнивание баблов в группе
This commit is contained in:
@@ -23,10 +23,16 @@ struct ChatListSearchContent: View {
|
||||
// MARK: - Active Search (Three States)
|
||||
|
||||
private extension ChatListSearchContent {
|
||||
/// Android-style: skeleton ↔ empty ↔ results — only one visible at a time.
|
||||
/// Desktop-parity: skeleton ↔ empty ↔ results — only one visible at a time.
|
||||
/// Local filtering uses `searchText` directly (NOT viewModel.searchQuery)
|
||||
/// to avoid @Published re-render cascade through ChatListView.
|
||||
@ViewBuilder
|
||||
var activeSearchContent: some View {
|
||||
let localResults = viewModel.filteredDialogs
|
||||
let query = searchText.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
// Local results: match by username ONLY (desktop parity — server matches usernames)
|
||||
let localResults = DialogRepository.shared.sortedDialogs.filter { dialog in
|
||||
!query.isEmpty && dialog.opponentUsername.lowercased().contains(query)
|
||||
}
|
||||
let localKeys = Set(localResults.map(\.opponentKey))
|
||||
let serverOnly = viewModel.serverSearchResults.filter {
|
||||
!localKeys.contains($0.publicKey)
|
||||
@@ -63,6 +69,7 @@ private extension ChatListSearchContent {
|
||||
}
|
||||
|
||||
/// Scrollable list of local dialogs + server results.
|
||||
/// Shows skeleton rows at the bottom while server is still searching.
|
||||
func resultsList(localResults: [Dialog], serverOnly: [SearchUser]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
@@ -84,11 +91,23 @@ private extension ChatListSearchContent {
|
||||
}
|
||||
}
|
||||
|
||||
// Skeleton loading rows while server search in progress
|
||||
if viewModel.isServerSearching {
|
||||
searchSkeletonRows
|
||||
}
|
||||
|
||||
Spacer().frame(height: 80)
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
}
|
||||
|
||||
/// Inline skeleton rows (3 shimmer placeholders) shown below existing results.
|
||||
private var searchSkeletonRows: some View {
|
||||
ForEach(0..<3, id: \.self) { _ in
|
||||
SearchSkeletonRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recent Searches
|
||||
|
||||
@@ -29,6 +29,7 @@ struct ChatListView: View {
|
||||
@StateObject private var viewModel = ChatListViewModel()
|
||||
@StateObject private var navigationState = ChatListNavigationState()
|
||||
@State private var searchText = ""
|
||||
@State private var hasPinnedChats = false
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
|
||||
@MainActor static var _bodyCount = 0
|
||||
@@ -42,6 +43,12 @@ struct ChatListView: View {
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 8)
|
||||
.background(
|
||||
(hasPinnedChats && !isSearchActive
|
||||
? RosettaColors.Dark.pinnedSectionBackground
|
||||
: Color.clear
|
||||
).ignoresSafeArea(.all, edges: .top)
|
||||
)
|
||||
|
||||
if isSearchActive {
|
||||
ChatListSearchContent(
|
||||
@@ -68,7 +75,7 @@ struct ChatListView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(isSearchActive ? .hidden : .visible, for: .navigationBar)
|
||||
.toolbar { toolbarContent }
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
.modifier(ChatListToolbarBackgroundModifier())
|
||||
.onChange(of: searchText) { _, newValue in
|
||||
viewModel.setSearchQuery(newValue)
|
||||
}
|
||||
@@ -223,7 +230,14 @@ private extension ChatListView {
|
||||
// without polluting ChatListView's observation scope.
|
||||
ChatListDialogContent(
|
||||
viewModel: viewModel,
|
||||
navigationState: navigationState
|
||||
navigationState: navigationState,
|
||||
onPinnedStateChange: { pinned in
|
||||
if hasPinnedChats != pinned {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
hasPinnedChats = pinned
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -235,60 +249,112 @@ private extension ChatListView {
|
||||
@ToolbarContentBuilder
|
||||
var toolbarContent: some ToolbarContent {
|
||||
if !isSearchActive {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { } label: {
|
||||
Text("Edit")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.glassCapsule()
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
// Isolated view — reads AccountManager & SessionManager (@Observable)
|
||||
// without polluting ChatListView's observation scope.
|
||||
ToolbarStoriesAvatar()
|
||||
Text("Chats")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 0) {
|
||||
if #available(iOS 26, *) {
|
||||
// iOS 26+ — original compact toolbar (no capsules, system icons)
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { } label: {
|
||||
Image("toolbar-add-chat")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 22, height: 22)
|
||||
.frame(width: 44, height: 44)
|
||||
Text("Edit")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
ToolbarStoriesAvatar()
|
||||
Text("Chats")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
Button { } label: {
|
||||
Image(systemName: "camera")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.accessibilityLabel("Camera")
|
||||
Button { } label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
.accessibilityLabel("New chat")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// iOS < 26 — capsule-styled toolbar with custom icons
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { } label: {
|
||||
Text("Edit")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Add chat")
|
||||
|
||||
Button { } label: {
|
||||
Image("toolbar-compose")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("New chat")
|
||||
.glassCapsule()
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
ToolbarStoriesAvatar()
|
||||
Text("Chats")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 0) {
|
||||
Button { } label: {
|
||||
Image("toolbar-add-chat")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 22, height: 22)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Add chat")
|
||||
|
||||
Button { } label: {
|
||||
Image("toolbar-compose")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("New chat")
|
||||
}
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.glassCapsule()
|
||||
}
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.glassCapsule()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar Background Modifier
|
||||
|
||||
private struct ChatListToolbarBackgroundModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26, *) {
|
||||
content
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.applyGlassNavBar()
|
||||
} else {
|
||||
content
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar Stories Avatar (observation-isolated)
|
||||
|
||||
/// Reads `AccountManager` and `SessionManager` in its own observation scope.
|
||||
@@ -341,15 +407,21 @@ private struct DeviceVerificationBannersContainer: View {
|
||||
private struct ChatListDialogContent: View {
|
||||
@ObservedObject var viewModel: ChatListViewModel
|
||||
@ObservedObject var navigationState: ChatListNavigationState
|
||||
var onPinnedStateChange: (Bool) -> Void = { _ in }
|
||||
@MainActor static var _bodyCount = 0
|
||||
|
||||
var body: some View {
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🔶 ChatListDialogContent.body #\(Self._bodyCount)")
|
||||
let hasPinned = !viewModel.pinnedDialogs.isEmpty
|
||||
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
|
||||
ChatEmptyStateView(searchText: "")
|
||||
.onChange(of: hasPinned) { _, val in onPinnedStateChange(val) }
|
||||
.onAppear { onPinnedStateChange(hasPinned) }
|
||||
} else {
|
||||
dialogList
|
||||
.onChange(of: hasPinned) { _, val in onPinnedStateChange(val) }
|
||||
.onAppear { onPinnedStateChange(hasPinned) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +438,8 @@ private struct ChatListDialogContent: View {
|
||||
if !viewModel.pinnedDialogs.isEmpty {
|
||||
ForEach(Array(viewModel.pinnedDialogs.enumerated()), id: \.element.id) { index, dialog in
|
||||
chatRow(dialog, isFirst: index == 0)
|
||||
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
|
||||
.environment(\.rowBackgroundColor, RosettaColors.Dark.pinnedSectionBackground)
|
||||
.listRowBackground(RosettaColors.Dark.pinnedSectionBackground)
|
||||
}
|
||||
}
|
||||
ForEach(Array(viewModel.unpinnedDialogs.enumerated()), id: \.element.id) { index, dialog in
|
||||
@@ -397,11 +470,6 @@ private struct ChatListDialogContent: View {
|
||||
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
withAnimation { viewModel.deleteDialog(dialog) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
Button {
|
||||
viewModel.toggleMute(dialog)
|
||||
} label: {
|
||||
@@ -410,7 +478,7 @@ private struct ChatListDialogContent: View {
|
||||
systemImage: dialog.isMuted ? "bell" : "bell.slash"
|
||||
)
|
||||
}
|
||||
.tint(.indigo)
|
||||
.tint(dialog.isMuted ? .green : .indigo)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
Button {
|
||||
@@ -422,7 +490,7 @@ private struct ChatListDialogContent: View {
|
||||
Button {
|
||||
viewModel.togglePin(dialog)
|
||||
} label: {
|
||||
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: "pin")
|
||||
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: dialog.isPinned ? "pin.slash" : "pin")
|
||||
}
|
||||
.tint(.orange)
|
||||
}
|
||||
|
||||
@@ -12,14 +12,15 @@ final class ChatListViewModel: ObservableObject {
|
||||
// MARK: - State
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var searchQuery = ""
|
||||
/// NOT @Published — avoids 2× body re-renders per keystroke in ChatListView.
|
||||
/// Local filtering uses `searchText` param directly in ChatListSearchContent.
|
||||
var searchQuery = ""
|
||||
@Published var serverSearchResults: [SearchUser] = []
|
||||
@Published var isServerSearching = false
|
||||
@Published var recentSearches: [RecentSearch] = []
|
||||
|
||||
private var searchTask: Task<Void, Never>?
|
||||
private var searchRetryTask: Task<Void, Never>?
|
||||
private var lastSearchedText = ""
|
||||
private var searchHandlerToken: UUID?
|
||||
private var recentSearchesCancellable: AnyCancellable?
|
||||
private let recentRepository = RecentSearchesRepository.shared
|
||||
@@ -32,19 +33,13 @@ final class ChatListViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Computed (local dialog filtering)
|
||||
// MARK: - Computed (dialog list for ChatListDialogContent)
|
||||
|
||||
/// Full dialog list — used by ChatListDialogContent which is only visible
|
||||
/// when search is NOT active. Search filtering is done separately in
|
||||
/// ChatListSearchContent using `searchText` parameter directly.
|
||||
var filteredDialogs: [Dialog] {
|
||||
var result = DialogRepository.shared.sortedDialogs
|
||||
let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
if !query.isEmpty {
|
||||
result = result.filter {
|
||||
$0.opponentTitle.lowercased().contains(query)
|
||||
|| $0.opponentUsername.lowercased().contains(query)
|
||||
|| $0.lastMessage.lowercased().contains(query)
|
||||
}
|
||||
}
|
||||
return result
|
||||
DialogRepository.shared.sortedDialogs
|
||||
}
|
||||
|
||||
var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) }
|
||||
@@ -120,17 +115,19 @@ final class ChatListViewModel: ObservableObject {
|
||||
private func triggerServerSearch() {
|
||||
searchTask?.cancel()
|
||||
searchTask = nil
|
||||
searchRetryTask?.cancel()
|
||||
searchRetryTask = nil
|
||||
|
||||
let trimmed = searchQuery.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty {
|
||||
serverSearchResults = []
|
||||
isServerSearching = false
|
||||
lastSearchedText = ""
|
||||
// Guard: only publish if value actually changes (avoids extra re-renders)
|
||||
if !serverSearchResults.isEmpty { serverSearchResults = [] }
|
||||
if isServerSearching { isServerSearching = false }
|
||||
return
|
||||
}
|
||||
|
||||
if trimmed == lastSearchedText { return }
|
||||
isServerSearching = true
|
||||
// Guard: don't re-publish true when already true
|
||||
if !isServerSearching { isServerSearching = true }
|
||||
|
||||
searchTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
@@ -139,36 +136,45 @@ final class ChatListViewModel: ObservableObject {
|
||||
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
||||
guard !currentQuery.isEmpty, currentQuery == trimmed else { return }
|
||||
|
||||
let connState = ProtocolManager.shared.connectionState
|
||||
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
|
||||
|
||||
guard connState == .authenticated, let hash else {
|
||||
self.isServerSearching = false
|
||||
// Reset so next attempt re-sends instead of being de-duped
|
||||
self.lastSearchedText = ""
|
||||
// Retry after 2 seconds if still have a query
|
||||
self.scheduleSearchRetry()
|
||||
return
|
||||
}
|
||||
|
||||
self.lastSearchedText = currentQuery
|
||||
var packet = PacketSearch()
|
||||
packet.privateKey = hash
|
||||
packet.search = currentQuery
|
||||
Self.logger.debug("📤 Sending search packet for '\(currentQuery)' with hash \(hash.prefix(10))...")
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
self.sendSearchPacket(query: currentQuery)
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleSearchRetry() {
|
||||
searchRetryTask?.cancel()
|
||||
searchRetryTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
let q = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
||||
guard !q.isEmpty else { return }
|
||||
self.triggerServerSearch()
|
||||
/// Sends PacketSearch if authenticated, otherwise waits for authentication (up to 10s).
|
||||
private func sendSearchPacket(query: String) {
|
||||
let connState = ProtocolManager.shared.connectionState
|
||||
let hash = SessionManager.shared.privateKeyHash ?? ProtocolManager.shared.privateHash
|
||||
|
||||
guard connState == .authenticated, let hash else {
|
||||
// Not authenticated — wait for reconnect then send
|
||||
Self.logger.debug("Search deferred — waiting for authentication")
|
||||
searchRetryTask?.cancel()
|
||||
searchRetryTask = Task { [weak self] in
|
||||
// Poll every 500ms for up to 10s (covers 5s reconnect + handshake)
|
||||
for _ in 0..<20 {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
let current = self.searchQuery.trimmingCharacters(in: .whitespaces)
|
||||
guard current == query else { return } // Query changed, abort
|
||||
if ProtocolManager.shared.connectionState == .authenticated {
|
||||
Self.logger.debug("Connection restored — sending pending search")
|
||||
self.sendSearchPacket(query: query)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Timed out
|
||||
guard let self else { return }
|
||||
Self.logger.warning("Search timed out waiting for authentication")
|
||||
self.isServerSearching = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var packet = PacketSearch()
|
||||
packet.privateKey = hash
|
||||
packet.search = query
|
||||
Self.logger.debug("📤 Sending search packet for '\(query)'")
|
||||
ProtocolManager.shared.sendPacket(packet)
|
||||
}
|
||||
|
||||
private func normalizeSearchInput(_ input: String) -> String {
|
||||
|
||||
@@ -173,17 +173,27 @@ private extension ChatRowView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop parity: clock only within 80s of send, then error.
|
||||
/// Delivered → single check, Read → double checks.
|
||||
private static let maxWaitingSeconds: TimeInterval = 80
|
||||
|
||||
@ViewBuilder
|
||||
var deliveryIcon: some View {
|
||||
switch dialog.lastMessageDelivered {
|
||||
case .waiting:
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
if isWithinWaitingWindow {
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
}
|
||||
case .delivered:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
case .read:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
@@ -202,6 +212,12 @@ private extension ChatRowView {
|
||||
}
|
||||
}
|
||||
|
||||
private var isWithinWaitingWindow: Bool {
|
||||
guard dialog.lastMessageTimestamp > 0 else { return true }
|
||||
let sentDate = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
|
||||
return Date().timeIntervalSince(sentDate) < Self.maxWaitingSeconds
|
||||
}
|
||||
|
||||
var unreadBadge: some View {
|
||||
let count = dialog.unreadCount
|
||||
let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)")
|
||||
|
||||
@@ -84,3 +84,50 @@ struct SearchSkeletonView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SearchSkeletonRow
|
||||
|
||||
/// Single shimmer row matching `serverUserRow` layout (48px avatar, two text lines).
|
||||
/// Used inline below existing search results while server is still loading.
|
||||
struct SearchSkeletonRow: View {
|
||||
@State private var phase: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 120, height: 14)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 90, height: 12)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.task {
|
||||
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shimmerGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.gray.opacity(0.08),
|
||||
Color.gray.opacity(0.15),
|
||||
Color.gray.opacity(0.08),
|
||||
],
|
||||
startPoint: UnitPoint(x: phase - 0.4, y: 0),
|
||||
endPoint: UnitPoint(x: phase + 0.4, y: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user