diff --git a/yobble/Views/Chat/PrivateChatView.swift b/yobble/Views/Chat/PrivateChatView.swift index 71eea04..004985a 100644 --- a/yobble/Views/Chat/PrivateChatView.swift +++ b/yobble/Views/Chat/PrivateChatView.swift @@ -8,6 +8,7 @@ struct PrivateChatView: View { @StateObject private var viewModel: PrivateChatViewModel @State private var hasPositionedToBottom: Bool = false @State private var scrollToBottomTrigger: UUID = .init() + @State private var isBottomAnchorVisible: Bool = true @State private var draftText: String = "" @State private var inputTab: ComposerTab = .chat @State private var isVideoPreferred: Bool = false @@ -21,26 +22,34 @@ struct PrivateChatView: View { } var body: some View { - ScrollViewReader { proxy in - content - .onChange(of: viewModel.messages.count) { _ in - guard !viewModel.isLoadingMore else { return } - let targetId = viewModel.messages.last?.id ?? bottomAnchorId - DispatchQueue.main.async { - withAnimation(.easeInOut(duration: 0.2)) { - proxy.scrollTo(targetId, anchor: .bottom) - } - hasPositionedToBottom = true - } - } - .onChange(of: scrollToBottomTrigger) { _ in - let targetId = viewModel.messages.last?.id ?? bottomAnchorId - DispatchQueue.main.async { - withAnimation(.easeInOut(duration: 0.2)) { - proxy.scrollTo(targetId, anchor: .bottom) + ZStack(alignment: .bottomTrailing) { + ScrollViewReader { proxy in + content + .onChange(of: viewModel.messages.count) { _ in + guard !viewModel.isLoadingMore else { return } + let targetId = viewModel.messages.last?.id ?? bottomAnchorId + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(targetId, anchor: .bottom) + } + hasPositionedToBottom = true } } - } + .onChange(of: scrollToBottomTrigger) { _ in + let targetId = viewModel.messages.last?.id ?? bottomAnchorId + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(targetId, anchor: .bottom) + } + } + } + } + + if !isBottomAnchorVisible { + scrollToBottomButton + .padding(.trailing, 20) + .padding(.bottom, 80) + } } .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) @@ -99,6 +108,8 @@ struct PrivateChatView: View { Color.clear .frame(height: 1) .id(bottomAnchorId) + .onAppear { isBottomAnchorVisible = true } + .onDisappear { isBottomAnchorVisible = false } } .padding(.vertical, 12) } @@ -233,9 +244,9 @@ struct PrivateChatView: View { Button(action: { }) { // переключатель на стикеры Image(systemName: "paperclip") .font(.system(size: 18, weight: .semibold)) - .frame(width: 40, height: 40) .foregroundColor(.secondary) } + .buttonStyle(ComposerIconButtonStyle()) ZStack(alignment: .bottomTrailing) { TextField(inputTab.placeholder, text: $draftText, axis: .vertical) @@ -269,10 +280,9 @@ struct PrivateChatView: View { Button(action: { isVideoPreferred.toggle() }) { Image(systemName: isVideoPreferred ? "video.fill" : "mic.fill") .font(.system(size: 18, weight: .semibold)) - .frame(width: 40, height: 40) .foregroundColor(.secondary) } - .buttonStyle(.plain) + .buttonStyle(ComposerIconButtonStyle()) } else { sendButton } @@ -284,6 +294,19 @@ struct PrivateChatView: View { .background(.ultraThinMaterial) } + private var scrollToBottomButton: some View { + Button(action: scrollToBottom) { + Image(systemName: "arrow.down") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background(Color.accentColor) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) + } + private var isSendDisabled: Bool { draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || currentUserId == nil } @@ -346,6 +369,26 @@ struct PrivateChatView: View { } } + private func scrollToBottom() { + scrollToBottomTrigger = .init() + hasPositionedToBottom = true + isBottomAnchorVisible = true + } + + private struct ComposerIconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(width: 36, height: 36) + .background(Color(.secondarySystemBackground)) + .clipShape(Circle()) + .overlay( + Circle().stroke(Color.secondary.opacity(0.15)) + ) + .scaleEffect(configuration.isPressed ? 0.94 : 1) + .opacity(configuration.isPressed ? 0.75 : 1) + } + } + private enum ComposerTab: String { case chat case stickers