У меня есть вопрос, связанный со SwiftUI. Пытаюсь сделать чат и сделать так, чтобы сообщения появлялись внизу экрана (как на экране из Telegram). Все решения, которые я пробовал, продолжают отображать сообщения вверху. Документация и
Первым делом я попробовал использовать модификатор defaultScrollAnchor(_ anchor:)
для ScrollView
. Это не сработало, и более того, я не заметил никаких изменений при использовании этого модификатора. Ни параметры .top
, .center
или .top
ничего не изменили. Я пробовал как статический, так и динамический список элементов в ScrollView
. Мой код внутри ScrollView
выглядит так:
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(viewModel.location.chat) { chat in
Text(chat.message)
.id(chat.id)
}
}
.frame(maxWidth: .infinity)
}
Затем я попытался прокрутить вниз, используя модификаторы onAppear{}
и onChange{}
с помощью ScrollViewReader
и scrollTo(_ id:anchor:)
. Это тоже не сработало. В крайнем случае задаю этот вопрос здесь. Spacer()
тоже не сработало. Вот мой полный код последней реализации:
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(viewModel.location.chat) { chat in
Text(chat.message)
.id(chat.id)
}
}
.frame(maxWidth: .infinity)
}
.onAppear {
scrollToBottom(proxy: proxy)
}
.onChange(of: viewModel.location.chat) {
scrollToBottom(proxy: proxy)
}
}
И scrollToBottom
функция:
private func scrollToBottom(proxy: ScrollViewProxy) {
DispatchQueue.main.async {
if let lastMessage = viewModel.location.chat.last {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
Здесь я выделил логику кода, сохраняющего структуру:
struct ContentView: View {
@StateObject var viewModel: ViewModel
var body: some View {
VStack {
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(viewModel.location.chat) { chat in
Text(chat.message)
.id(chat.id)
}
}
.frame(maxWidth: .infinity)
}
.onAppear {
scrollToBottom(proxy: proxy)
}
.onChange(of: viewModel.location.chat) {
scrollToBottom(proxy: proxy)
}
}
HStack {
TextField("Message", text: $viewModel.currentMessage)
.frame(height: 40)
.padding([.leading, .trailing], 12)
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(Color.black.opacity(0.4))
}
Button {
viewModel.sendMessage()
} label: {
Image(systemName: "paperplane.fill")
.foregroundStyle(Color.white)
.frame(width: 40, height: 40)
.background(Color.accentColor)
.clipShape(Circle())
}
}
.padding([.leading, .trailing, .bottom], 12)
}
}
private func scrollToBottom(proxy: ScrollViewProxy) {
DispatchQueue.main.async {
if let lastMessage = viewModel.location.chat.last {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
}
#Preview {
ContentView(viewModel: ViewModel(location: Location(name: "Test locaiton")))
}
struct Location: Identifiable {
var id = UUID()
var name: String
var chat: [StringChatMessage]
init(name: String) {
self.name = name
self.chat = []
}
}
struct StringChatMessage: Identifiable, Equatable {
var id = UUID()
var isUser: Bool
var message: String
}
@MainActor
class ViewModel: ObservableObject {
@Published var location: Location
@Published var currentMessage: String = ""
init(location: Location) {
self.location = location
}
func sendMessage() {
guard !currentMessage.isEmpty else { return }
location.chat.append(StringChatMessage(isUser: true, message: currentMessage))
location.chat.append(StringChatMessage(isUser: false, message: "*reply*"))
currentMessage = ""
}
}
Предоставьте достаточно кода, чтобы другие могли лучше понять или воспроизвести проблему.
@BenzyNeez Я обновил пост и добавил отдельный пример, сохранив структуру.
@BenzyNeez Также я попробовал запустить эту реализацию как на симуляторе, так и на устройстве. Это не работает в обоих случаях
Чтобы сообщения чата отображались внизу экрана в приложении чата SwiftUI, вы можете использовать ScrollViewReader
для программного управления положением прокрутки. Вот рабочее решение:
import SwiftUI
struct ChatView: View {
@ObservedObject var viewModel: ChatViewModel
var body: some View {
ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(viewModel.location.chat) { chat in
Text(chat.message)
.id(chat.id)
.padding()
}
}
.frame(maxWidth: .infinity)
}
.onAppear {
scrollToBottom(proxy: proxy)
}
.onChange(of: viewModel.location.chat) { _ in
scrollToBottom(proxy: proxy)
}
}
}
private func scrollToBottom(proxy: ScrollViewProxy) {
if let lastMessage = viewModel.location.chat.last {
withAnimation {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
}
class ChatViewModel: ObservableObject {
@Published var location: ChatLocation
init(location: ChatLocation) {
self.location = location
}
}
struct ChatLocation {
var chat: [ChatMessage]
}
struct ChatMessage: Identifiable {
let id: UUID
let message: String
}
// Example usage with mock data
struct ContentView: View {
@StateObject var viewModel = ChatViewModel(location: ChatLocation(chat: [
ChatMessage(id: UUID(), message: "Hello!"),
ChatMessage(id: UUID(), message: "How are you?"),
// Add more messages as needed
]))
var body: some View {
ChatView(viewModel: viewModel)
}
}
Убедитесь, что ваш viewModel.location.chat
правильно идентифицирован как @Published
, чтобы SwiftUI мог реагировать на изменения.
Используйте ScrollViewReader
с onAppear
и onChange
для автоматической прокрутки вниз при каждом добавлении сообщений или появлении представления.
Кажется, это именно то, что я сейчас делаю, но сообщения продолжают появляться вверху. Я проверил, и location
внутри моей модели представления отмечен как @Published
. Дело в том, что это решение не работает, и я понятия не имею, в чем здесь может быть проблема.
Спасибо, что обновили пост и включили больше кода.
Когда я пробую код на симуляторе iPhone 15 под управлением iOS 17.5 (Xcode 15.4), у меня он работает нормально.
ViewModel
с кучей сообщений, при запуске он прокручивается до последнего сообщения.Чтобы предварительно загрузить сообщения, я обновил ViewModel.init
:
init(location: Location) {
self.location = location
for i in 1...100 {
currentMessage = "Message \(i)"
sendMessage()
}
}
Так что я действительно не знаю, почему у вас это не работает. Тем не менее, есть несколько вещей, которые я бы посоветовал вам изменить. Я не думаю, что изменения помогут решить проблему (которую я не могу воспроизвести), но они могут помочь немного улучшить код:
viewModel
в ContentView
, используйте @ObservedObject
вместо @StateObject
. Альтернативно, сохраните его как @StateObject
и создайте модель в ContentView
. В последнем случае также должно быть private
:// ContentView
@StateObject private var viewModel = ViewModel(
location: Location(name: "Test location")
)
Location
и StringChatMessage
используйте let
вместо id
:let id = UUID()
StringChatMessage
реализует Identifiable
, нет необходимости устанавливать .id
для элементов в ForEach
:ForEach(viewModel.location.chat) { chat in
Text(chat.message)
// .id(chat.id)
}
scrollToBottom
нет необходимости вызывать .scrollTo
асинхронно. Однако вы можете назвать его withAnimation
, чтобы прокрутка была анимированной:private func scrollToBottom(proxy: ScrollViewProxy) {
if let lastMessage = viewModel.location.chat.last {
withAnimation {
proxy.scrollTo(lastMessage.id, anchor: .bottom)
}
}
}
ViewModel
как currentMessage
и затем отправляется при вызове sendMessage
. Но действительно ли будут еще какие-то наблюдатели текущего сообщения, кроме ContentView
? Я бы предложил сделать currentMessage
переменную @State
в ContentView
и передать текст сообщения в качестве параметра sendMessage
:// ViewModel
// @Published var currentMessage: String = ""
func sendMessage(text: String) {
location.chat.append(StringChatMessage(isUser: true, message: text))
location.chat.append(StringChatMessage(isUser: false, message: "*reply*"))
}
// ContentView
@State private var currentMessage: String = ""
TextField("Message", text: $currentMessage)
// ... modifiers as before
Button {
viewModel.sendMessage(text: currentMessage)
currentMessage = ""
} label: {
// ...
}
.scrollPosition
вместе с .scrollTargetLayout
и вообще удалить ScrollViewReader
. Это может быть проще, не в последнюю очередь потому, что вам не нужно передавать параметр ScrollViewProxy
как параметр scrollToBottom
:// ContentView
@State private var scrollPosition: UUID?
// ScrollViewReader { proxy in
ScrollView(showsIndicators: false) {
LazyVStack {
// ...
}
.scrollTargetLayout()
.frame(maxWidth: .infinity)
}
.scrollPosition(id: $scrollPosition, anchor: .bottom)
.onAppear {
scrollToBottom()
}
.onChange(of: viewModel.location.chat) {
scrollToBottom()
}
// }
private func scrollToBottom() {
if let lastMessage = viewModel.location.chat.last {
withAnimation {
scrollPosition = lastMessage.id
}
}
}
initial: true
в модификатор .onChange
, вы можете удалить обратный вызов .onAppear
. Вы также можете получить последнее сообщение из обновленного массива, которое передается замыканию, что означает, что вы можете выполнить всю автоматическую прокрутку в обратном вызове .onChange
и удалить функцию scrollToBottom
.Вот полностью обновленный ContentView
, который работает следующим образом:
struct ContentView: View {
@StateObject private var viewModel = ViewModel(
location: Location(name: "Test location")
)
@State private var currentMessage: String = ""
@State private var scrollPosition: UUID?
var body: some View {
VStack {
ScrollView(showsIndicators: false) {
LazyVStack {
ForEach(viewModel.location.chat) { chat in
Text(chat.message)
}
}
.scrollTargetLayout()
.frame(maxWidth: .infinity)
}
.scrollPosition(id: $scrollPosition, anchor: .bottom)
.onChange(of: viewModel.location.chat, initial: true) { oldVal, newVal in
if let lastMessage = newVal.last {
withAnimation {
scrollPosition = lastMessage.id
}
}
}
HStack {
TextField("Message", text: $currentMessage)
.frame(height: 40)
.padding([.leading, .trailing], 12)
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(Color.black.opacity(0.4))
}
Button {
viewModel.sendMessage(text: currentMessage)
currentMessage = ""
} label: {
Image(systemName: "paperplane.fill")
.foregroundStyle(Color.white)
.frame(width: 40, height: 40)
.background(Color.accentColor)
.clipShape(Circle())
}
}
.padding([.leading, .trailing, .bottom], 12)
}
}
}
РЕДАКТИРОВАТЬ В своем комментарии вы объяснили, что хотите, чтобы содержимое внутри ScrollView
начиналось снизу, а не сверху. Чтобы это работало следующим образом:
ScrollView
GeometryReader
, чтобы измерить используемое пространствоScrollView
как minHeight
на LazyVStack
, с выравниванием .bottom
.GeometryReader { proxy in
ScrollView(showsIndicators: false) {
LazyVStack {
// ...
}
.scrollTargetLayout()
.frame(maxWidth: .infinity, minHeight: proxy.size.height, alignment: .bottom)
}
// ...other modifiers as before
}
EDIT2 Что касается анимации - если я правильно понимаю, вы хотите, чтобы новое сообщение добавлялось внизу пустого ScrollView
с какой-то анимацией нажатия снизу, верно? Вы можете попробовать эти изменения:
.transition
к Text
, показывающему сообщение:Text(chat.message)
.transition(.push(from: .bottom))
sendMessage
с помощью withAnimation
:Button {
withAnimation {
viewModel.sendMessage(text: currentMessage)
}
currentMessage = ""
} label: {
// ...
}
Спасибо за полезные предложения по улучшению моего кода. Я обязательно буду использовать их. Как я вижу, мой вопрос был недостаточно ясен. Мой ScrollView
ведет себя точно так же, как вы описали в своем списке в начале ответа. Но это не то поведение, которого мне нужно достичь. Мне нужно, чтобы сообщения появлялись внизу рядом с текстом, введенным с самого начала. Я предоставил скриншот желаемого поведения в верхней части моего вопроса. Пожалуйста, посмотрите пример здесь
@Саша ОК, обновление добавлено в конец ответа
Большое спасибо! Это решение наконец работает. У меня есть еще один небольшой вопрос относительно этого решения. Анимация применяется только тогда, когда сообщения охватывают весь ScrollView
. Я пробовал применять анимацию при нажатии кнопки, но они отличаются по стилю. Я понимаю, почему так происходит, но мне не ясно, как это сделать в одном стиле. Если оставить анимацию только в кнопке, анимация прекращается, если сообщений много.
@Саша Рада, что смогла помочь, спасибо, что приняли ответ! Что касается анимации — ответ снова обновлен с некоторыми предлагаемыми изменениями. Если это работает не совсем так, как вы хотите, я бы предложил создать новый пост для изучения других решений — обязательно укажите рабочий код в качестве отправной точки!
Ваш код выглядит хорошо, если предположить, что ваши элементы
Chat
реализуютIdentifable
. Я попробовал это с фиктивной реализациейChat
, и у меня это работает,ScrollView
прокручивается до последнего элемента чата при первом показе. Попробуйте проверить, как определяются идентификаторы ваших элементов чата — убедитесь, что идентификаторы не изменяются (например, если копируется массив, хранящийся в вашемviewModel
). Вы также можете попробовать использовать.scrollPosition
вместе с.scrollTargetLayout
вместоScrollViewReader
. Для получения дополнительной помощи дополните пример кода, чтобы он был полным и работал изолированно.