Исправление проблем с отскоком SwiftUI LazyVStack при закрытии клавиатуры
Узнайте, как исправить проблему SwiftUI LazyVStack, которая не возвращается в исходное положение после закрытия клавиатуры в iOS 17-18. Решения для разных размеров представлений и управления позицией прокрутки.
LazyVStack в SwiftUI не возвращается в исходное положение после закрытия клавиатуры в iOS 17 и 18
Я столкнулся с проблемой в LazyVStack в SwiftUI, где она не правильно возвращается в исходное положение после закрытия клавиатуры в iOS 17 и 18. Я заметил, что когда все представления внутри LazyVStack имеют одинаковый размер, проблем не возникает. Однако, когда размеры представлений различаются, возникает эта проблема.
Вот моя реализация:
struct MessagesView: View {
@State private var messages: [ChatMessage] = MockChatMessages().loadAllMessages()
@State private var inputText: String = ""
@Binding var showChat: Bool
@State private var scrollToID: Int? // Используется для авто-прокрутки в iOS 17
var body: some View {
VStack(spacing: 0) {
HeaderView()
MessagesList(messages: messages, scrollToID: $scrollToID)
InputBar(inputText: $inputText, onSend: sendMessage)
}
.background(Color.blue.opacity(0.3))
.ignoresSafeArea(edges: .top)
.onAppear {
scrollToID = messages.last?.id
}
.onChange(of: messages.count) { _ in
scrollToID = messages.last?.id
}
}
}
// MARK: - Список сообщений
struct MessagesList: View {
var messages: [ChatMessage]
@Binding var scrollToID: Int?
var body: some View {
if #available(iOS 17.0, *) {
ScrollView {
LazyVStack(spacing: 14) {
ForEach(messages, id: \.id) { msg in
MessageBubble(message: msg)
}
}
.padding(.vertical)
.background(Color.yellow.opacity(0.5))
}
.background(Color.green.opacity(0.5))
.scrollIndicators(.hidden)
.scrollPosition(id: $scrollToID, anchor: .bottom)
} else {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 14) {
ForEach(messages, id: \.id) { msg in
MessageBubble(message: msg)
.id(msg.id)
}
}
.padding(.vertical)
}
.onChange(of: scrollToID) { id in
if let id = id {
withAnimation {
proxy.scrollTo(id, anchor: .bottom)
}
}
}
}
}
}
}
// MARK: - Один пузырь сообщения
struct MessageBubble: View {
var message: ChatMessage
var isRight: Bool { message.direction == .right }
var body: some View {
HStack {
if isRight { Spacer() }
Text(message.message)
.foregroundColor(isRight ? .white : .black)
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background(isRight ? Color.black : Color.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
.frame(maxWidth: UIScreen.main.bounds.width * 0.7, alignment: isRight ? .trailing : .leading)
if !isRight { Spacer() }
}
.padding(.horizontal, 12)
}
}
Как можно исправить проблему, когда LazyVStack не возвращается в исходное положение после закрытия клавиатуры в iOS 17 и 18, особенно когда размеры представлений различаются?
Проблема с LazyVStack, который не возвращается в исходное положение после закрытия клавиатуры в iOS 17 и 18
Проблема с LazyVStack, который не возвращается в исходное положение после закрытия клавиатуры в iOS 17 и 18, является распространенной проблемой, связанной с управлением положением прокрутки при изменении размера контента. При использовании нового модификатора .scrollPosition с взаимодействиями клавиатуры, представление прокрутки может потерять свое целевое положение из-за проблем с синхронизацией и управлением состоянием.
Содержание
- Понимание проблемы
- Анализ корневой причины
- Решения для iOS 17 и 18
- Исправления реализации
- Дополнительные рекомендации
- Тестирование и проверка
Понимание проблемы
Ваша реализация использует новый модификатор iOS 17 .scrollPosition(id: $scrollToID, anchor: .bottom), который предоставляет более современный подход к управлению положением прокрутки по сравнению со старым шаблоном ScrollViewReader. Однако этот новый API имеет некоторые нюансы, которые могут вызывать проблемы при закрытии клавиатуры.
Проблема проявляется особенно тогда, когда размеры представлений различаются, потому что:
- размер контента представления прокрутки изменяется динамически
- вычисления положения якоря становятся более сложными
- время анимации между закрытием клавиатуры и обновлением положения прокрутки может выйти из синхронизации
Как указано в документации по поведению прокрутки SwiftUI, интервалы и размеры контента значительно влияют на вычисления смещения прокрутки, что объясняет, почему проблема возникает при разных размерах пузырей сообщений, но не при равномерных размерах [источник: ScrollViewSnappingInSwiftUI].
Анализ корневой причины
Основная проблема связана с синхронизацией времени между закрытием клавиатуры и обновлением положения прокрутки. Вот что происходит:
- Появляется клавиатура: представление прокрутки корректирует свое смещение контента, чтобы активное поле ввода оставалось видимым
- Пользователь закрывает клавиатуру: начинается анимация закрытия клавиатуры, изменяющая доступный размер контента
- Обновление положения прокрутки: модификатор
.scrollPositionпытается вернуться в исходное положение - Состояние гонки: обновление положения прокрутки и анимация закрытия клавиатуры конкурируют, вызывая неправильное положение представления прокрутки
Это особенно проблематично с LazyVStack, потому что:
- динамическая загрузка контента влияет на внутреннее состояние представления прокрутки
- вычисления положения прокрутки должны учитывать загруженный и незагруженный контент
- положение якоря может быть неправильно пересчитано во время перехода
Решения для iOS 17 и 18
Решение 1: Ручной сброс положения прокрутки
Добавьте наблюдатели за закрытием клавиатуры для ручного сброса положения прокрутки:
struct MessagesList: View {
var messages: [ChatMessage]
@Binding var scrollToID: Int?
@State private var isKeyboardVisible = false
var body: some View {
if #available(iOS 17.0, *) {
ScrollView {
LazyVStack(spacing: 14) {
ForEach(messages, id: \.id) { msg in
MessageBubble(message: msg)
}
}
.padding(.vertical)
.background(Color.yellow.opacity(0.5))
}
.background(Color.green.opacity(0.5))
.scrollIndicators(.hidden)
.scrollPosition(id: $scrollToID, anchor: .bottom)
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
// Сброс положения прокрутки после закрытия клавиатуры
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollToID = messages.last?.id
}
}
} else {
// Существующая реализация для iOS 16
}
}
}
Решение 2: ScrollViewReader с iOS 17
Для более надежного управления используйте ScrollViewReader даже в iOS 17:
struct MessagesList: View {
var messages: [ChatMessage]
@Binding var scrollToID: Int?
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 14) {
ForEach(messages, id: \.id) { msg in
MessageBubble(message: msg)
.id(msg.id)
}
}
.padding(.vertical)
.background(Color.yellow.opacity(0.5))
}
.background(Color.green.opacity(0.5))
.scrollIndicators(.hidden)
.onChange(of: scrollToID) { id in
if let id = id {
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(id, anchor: .bottom)
}
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
// Обеспечение плавного возврата в низ
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if let lastID = messages.last?.id {
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(lastID, anchor: .bottom)
}
}
}
}
}
}
}
Решение 3: Комбинированный подход с управлением состоянием
Реализуйте более надежный подход к управлению состоянием:
struct MessagesList: View {
var messages: [ChatMessage]
@Binding var scrollToID: Int?
@State private var scrollTarget: MessageTarget = .bottom
enum MessageTarget {
case bottom
case specific(Int)
}
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 14) {
ForEach(messages, id: \.id) { msg in
MessageBubble(message: msg)
.id(msg.id)
}
}
.padding(.vertical)
.background(Color.yellow.opacity(0.5))
}
.background(Color.green.opacity(0.5))
.scrollIndicators(.hidden)
.onChange(of: scrollTarget) { target in
switch target {
case .bottom:
if let lastID = messages.last?.id {
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(lastID, anchor: .bottom)
}
}
case .specific(let id):
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(id, anchor: .bottom)
}
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
scrollTarget = .bottom
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
// Сохранение текущего положения при появлении клавиатуры
scrollTarget = .specific(scrollToID ?? messages.last?.id ?? 0)
}
}
.onChange(of: scrollToID) { id in
scrollTarget = .specific(id ?? 0)
}
}
}
Исправления реализации
Исправление 1: Обновление MessagesView
Измените ваше основное представление для более эффективной обработки событий клавиатуры:
struct MessagesView: View {
@State private var messages: [ChatMessage] = MockChatMessages().loadAllMessages()
@State private var inputText: String = ""
@Binding var showChat: Bool
@State private var scrollToID: Int? // Используется для авто-прокрутки в iOS 17
var body: some View {
VStack(spacing: 0) {
HeaderView()
MessagesList(messages: messages, scrollToID: $scrollToID)
InputBar(inputText: $inputText, onSend: sendMessage)
}
.background(Color.blue.opacity(0.3))
.ignoresSafeArea(edges: .top)
.onAppear {
scrollToID = messages.last?.id
}
.onChange(of: messages.count) { _ in
scrollToID = messages.last?.id
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
// Обеспечение сброса положения прокрутки после закрытия клавиатуры
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollToID = messages.last?.id
}
}
}
}
Исправление 2: Оптимизация размера пузыря сообщений
Обеспечьте согласованное поведение изменения размера для минимизации проблем с положением прокрутки:
struct MessageBubble: View {
var message: ChatMessage
var isRight: Bool { message.direction == .right }
var body: some View {
HStack {
if isRight { Spacer() }
VStack(alignment: isRight ? .trailing : .leading, spacing: 4) {
Text(message.message)
.foregroundColor(isRight ? .white : .black)
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background(isRight ? Color.black : Color.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
.frame(maxWidth: UIScreen.main.bounds.width * 0.7, alignment: isRight ? .trailing : .leading)
// Добавление временной метки или других согласованных элементов
Text("10:30 AM")
.font(.caption2)
.foregroundColor(.gray)
}
if !isRight { Spacer() }
}
.padding(.horizontal, 12)
.frame(minHeight: 50) // Обеспечение согласованного минимального высоты
}
}
Исправление 3: Добавление поведения избегания клавиатуры
Реализуйте правильное избегание клавиатуры для предотвращения прыжков контента:
struct MessagesList: View {
var messages: [ChatMessage]
@Binding var scrollToID: Int?
@State private var keyboardHeight: CGFloat = 0
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 14) {
ForEach(messages, id: \.id) { msg in
MessageBubble(message: msg)
.id(msg.id)
}
}
.padding(.vertical)
.background(Color.yellow.opacity(0.5))
}
.background(Color.green.opacity(0.5))
.scrollIndicators(.hidden)
.contentOffset(y: keyboardHeight > 0 ? -keyboardHeight/2 : 0) // Корректировка для клавиатуры
.onChange(of: scrollToID) { id in
if let id = id {
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(id, anchor: .bottom)
}
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in
if let keyboardSize = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
keyboardHeight = keyboardSize.height
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
keyboardHeight = 0
// Сброс положения прокрутки после полного скрытия клавиатуры
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if let lastID = messages.last?.id {
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(lastID, anchor: .bottom)
}
}
}
}
}
}
}
Дополнительные рекомендации
1. Последовательно используйте ScrollViewReader
Хотя модификатор .scrollPosition в iOS 17 более современен, ScrollViewReader обеспечивает более надежное управление анимациями прокрутки и положением, особенно во взаимодействиях с клавиатурой.
2. Реализуйте правильное время анимации
Используйте согласованные длительности и кривые анимации:
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(id, anchor: .bottom)
}
3. Добавляйте наблюдатели за размером контента
Мониторьте изменения размера контента для соответствующей корректировки положения прокрутки:
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
// Обновление положения прокрутки при возврате приложения в foreground
scrollToID = messages.last?.id
}
4. Рассмотрите использование GeometryReader для компоновки
Для сложных компоновок используйте GeometryReader для получения точных измерений:
GeometryReader { geometry in
ScrollView {
LazyVStack {
// Контент с точными расчетами ширины
}
.frame(width: geometry.size.width)
}
}
Тестирование и проверка
Для обеспечения правильной работы вашего исправления:
- Тестируйте с различными длинами сообщений: Создавайте тестовые сообщения разной длины для проверки поведения
- Тестируйте быстрое печатание: Отправляйте несколько сообщений быстро для проверки стабильности положения прокрутки
- Тестируйте в альбомной ориентации: Убедитесь, что поведение работает в разных ориентациях устройства
- Тестируйте на разных версиях iOS: Проверьте, что исправление работает на iOS 17.0+ и не ломает работу на более ранних версиях
- Тестируйте с разными типами клавиатур: Тестируйте с системными клавиатурами, пользовательскими клавиатурами и вспомогательными клавиатурами
Ключевое понимание заключается в том, что управление положением прокрутки в SwiftUI требует тщательного внимания к времени, особенно при работе с взаимодействиями клавиатуры и динамической загрузкой контента. Реализуя правильные наблюдатели за клавиатурой, используя ScrollViewReader для более детального контроля и добавляя соответствующие задержки для анимаций, вы можете решить проблемы поведения при щелчке в iOS 17 и 18.
Источники
Заключение
Чтобы исправить проблему с LazyVStack, который не возвращается в исходное положение после закрытия клавиатуры в iOS 17 и 18:
- Используйте ScrollViewReader вместо
.scrollPositionдля более надежного управления прокруткой - Добавляйте наблюдатели за закрытием клавиатуры с соответствующими задержками для сброса положения прокрутки
- Реализуйте согласованное время анимации между анимациями клавиатуры и прокрутки
- Обеспечивайте минимальные высоты представлений для предотвращения несоответствий размера контента
- Тщательно тестируйте с различными длинами сообщений и взаимодействиями клавиатуры
Наиболее надежным решением является использование ScrollViewReader с наблюдателями событий клавиатуры, как показано в Решении 2 выше. Этот подход обеспечивает необходимый контроль над анимациями прокрутки и управлением положением, обеспечивая правильное поведение даже при различных размерах представлений.