Как я могу сделать так, чтобы элементы отображались внизу ScrollView?

У меня есть вопрос, связанный со 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 = ""
    }
}

Ваш код выглядит хорошо, если предположить, что ваши элементы Chat реализуют Identifable. Я попробовал это с фиктивной реализацией Chat, и у меня это работает, ScrollView прокручивается до последнего элемента чата при первом показе. Попробуйте проверить, как определяются идентификаторы ваших элементов чата — убедитесь, что идентификаторы не изменяются (например, если копируется массив, хранящийся в вашем viewModel). Вы также можете попробовать использовать .scrollPosition вместе с .scrollTargetLayout вместо ScrollViewReader. Для получения дополнительной помощи дополните пример кода, чтобы он был полным и работал изолированно.

Benzy Neez 05.08.2024 19:26

Предоставьте достаточно кода, чтобы другие могли лучше понять или воспроизвести проблему.

Community 05.08.2024 20:07

@BenzyNeez Я обновил пост и добавил отдельный пример, сохранив структуру.

Sasha 05.08.2024 23:10

@BenzyNeez Также я попробовал запустить эту реализацию как на симуляторе, так и на устройстве. Это не работает в обоих случаях

Sasha 05.08.2024 23:12
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
4
77
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

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

Sasha 05.08.2024 19:15
Ответ принят как подходящий

Спасибо, что обновили пост и включили больше кода.

Когда я пробую код на симуляторе 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()
    }
}

Так что я действительно не знаю, почему у вас это не работает. Тем не менее, есть несколько вещей, которые я бы посоветовал вам изменить. Я не думаю, что изменения помогут решить проблему (которую я не могу воспроизвести), но они могут помочь немного улучшить код:

  1. Если вы хотите передать viewModel в ContentView, используйте @ObservedObject вместо @StateObject. Альтернативно, сохраните его как @StateObject и создайте модель в ContentView. В последнем случае также должно быть private:
// ContentView
@StateObject private var viewModel = ViewModel(
    location: Location(name: "Test location")
)
  1. В Location и StringChatMessage используйте let вместо id:
let id = UUID()
  1. Поскольку StringChatMessage реализует Identifiable, нет необходимости устанавливать .id для элементов в ForEach:
ForEach(viewModel.location.chat) { chat in
    Text(chat.message)
        // .id(chat.id)
}
  1. В функции scrollToBottom нет необходимости вызывать .scrollTo асинхронно. Однако вы можете назвать его withAnimation, чтобы прокрутка была анимированной:
private func scrollToBottom(proxy: ScrollViewProxy) {
    if let lastMessage = viewModel.location.chat.last {
        withAnimation {
            proxy.scrollTo(lastMessage.id, anchor: .bottom)
        }
    }
}
  1. В настоящее время черновик сообщения сохраняется в 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: {
    // ...
}
  1. Как я предлагал в комментарии, вы можете использовать .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
        }
    }
}
  1. Если вы укажете 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 ведет себя точно так же, как вы описали в своем списке в начале ответа. Но это не то поведение, которого мне нужно достичь. Мне нужно, чтобы сообщения появлялись внизу рядом с текстом, введенным с самого начала. Я предоставил скриншот желаемого поведения в верхней части моего вопроса. Пожалуйста, посмотрите пример здесь

Sasha 06.08.2024 17:16

@Саша ОК, обновление добавлено в конец ответа

Benzy Neez 06.08.2024 17:30

Большое спасибо! Это решение наконец работает. У меня есть еще один небольшой вопрос относительно этого решения. Анимация применяется только тогда, когда сообщения охватывают весь ScrollView. Я пробовал применять анимацию при нажатии кнопки, но они отличаются по стилю. Я понимаю, почему так происходит, но мне не ясно, как это сделать в одном стиле. Если оставить анимацию только в кнопке, анимация прекращается, если сообщений много.

Sasha 06.08.2024 18:07

@Саша Рада, что смогла помочь, спасибо, что приняли ответ! Что касается анимации — ответ снова обновлен с некоторыми предлагаемыми изменениями. Если это работает не совсем так, как вы хотите, я бы предложил создать новый пост для изучения других решений — обязательно укажите рабочий код в качестве отправной точки!

Benzy Neez 06.08.2024 18:38

Другие вопросы по теме