У меня есть вопрос, связанный со 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 начиналось снизу, а не сверху. Чтобы это работало следующим образом:
ScrollViewGeometryReader, чтобы измерить используемое пространство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. Для получения дополнительной помощи дополните пример кода, чтобы он был полным и работал изолированно.