Другое

Исправление проблем с отскоком SwiftUI LazyVStack при закрытии клавиатуры

Узнайте, как исправить проблему SwiftUI LazyVStack, которая не возвращается в исходное положение после закрытия клавиатуры в iOS 17-18. Решения для разных размеров представлений и управления позицией прокрутки.

LazyVStack в SwiftUI не возвращается в исходное положение после закрытия клавиатуры в iOS 17 и 18

Я столкнулся с проблемой в LazyVStack в SwiftUI, где она не правильно возвращается в исходное положение после закрытия клавиатуры в iOS 17 и 18. Я заметил, что когда все представления внутри LazyVStack имеют одинаковый размер, проблем не возникает. Однако, когда размеры представлений различаются, возникает эта проблема.

Вот моя реализация:

swift
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 .scrollPosition(id: $scrollToID, anchor: .bottom), который предоставляет более современный подход к управлению положением прокрутки по сравнению со старым шаблоном ScrollViewReader. Однако этот новый API имеет некоторые нюансы, которые могут вызывать проблемы при закрытии клавиатуры.

Проблема проявляется особенно тогда, когда размеры представлений различаются, потому что:

  • размер контента представления прокрутки изменяется динамически
  • вычисления положения якоря становятся более сложными
  • время анимации между закрытием клавиатуры и обновлением положения прокрутки может выйти из синхронизации

Как указано в документации по поведению прокрутки SwiftUI, интервалы и размеры контента значительно влияют на вычисления смещения прокрутки, что объясняет, почему проблема возникает при разных размерах пузырей сообщений, но не при равномерных размерах [источник: ScrollViewSnappingInSwiftUI].

Анализ корневой причины

Основная проблема связана с синхронизацией времени между закрытием клавиатуры и обновлением положения прокрутки. Вот что происходит:

  1. Появляется клавиатура: представление прокрутки корректирует свое смещение контента, чтобы активное поле ввода оставалось видимым
  2. Пользователь закрывает клавиатуру: начинается анимация закрытия клавиатуры, изменяющая доступный размер контента
  3. Обновление положения прокрутки: модификатор .scrollPosition пытается вернуться в исходное положение
  4. Состояние гонки: обновление положения прокрутки и анимация закрытия клавиатуры конкурируют, вызывая неправильное положение представления прокрутки

Это особенно проблематично с LazyVStack, потому что:

  • динамическая загрузка контента влияет на внутреннее состояние представления прокрутки
  • вычисления положения прокрутки должны учитывать загруженный и незагруженный контент
  • положение якоря может быть неправильно пересчитано во время перехода

Решения для iOS 17 и 18

Решение 1: Ручной сброс положения прокрутки

Добавьте наблюдатели за закрытием клавиатуры для ручного сброса положения прокрутки:

swift
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:

swift
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: Комбинированный подход с управлением состоянием

Реализуйте более надежный подход к управлению состоянием:

swift
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

Измените ваше основное представление для более эффективной обработки событий клавиатуры:

swift
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: Оптимизация размера пузыря сообщений

Обеспечьте согласованное поведение изменения размера для минимизации проблем с положением прокрутки:

swift
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: Добавление поведения избегания клавиатуры

Реализуйте правильное избегание клавиатуры для предотвращения прыжков контента:

swift
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. Реализуйте правильное время анимации

Используйте согласованные длительности и кривые анимации:

swift
withAnimation(.easeInOut(duration: 0.3)) {
    proxy.scrollTo(id, anchor: .bottom)
}

3. Добавляйте наблюдатели за размером контента

Мониторьте изменения размера контента для соответствующей корректировки положения прокрутки:

swift
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
    // Обновление положения прокрутки при возврате приложения в foreground
    scrollToID = messages.last?.id
}

4. Рассмотрите использование GeometryReader для компоновки

Для сложных компоновок используйте GeometryReader для получения точных измерений:

swift
GeometryReader { geometry in
    ScrollView {
        LazyVStack {
            // Контент с точными расчетами ширины
        }
        .frame(width: geometry.size.width)
    }
}

Тестирование и проверка

Для обеспечения правильной работы вашего исправления:

  1. Тестируйте с различными длинами сообщений: Создавайте тестовые сообщения разной длины для проверки поведения
  2. Тестируйте быстрое печатание: Отправляйте несколько сообщений быстро для проверки стабильности положения прокрутки
  3. Тестируйте в альбомной ориентации: Убедитесь, что поведение работает в разных ориентациях устройства
  4. Тестируйте на разных версиях iOS: Проверьте, что исправление работает на iOS 17.0+ и не ломает работу на более ранних версиях
  5. Тестируйте с разными типами клавиатур: Тестируйте с системными клавиатурами, пользовательскими клавиатурами и вспомогательными клавиатурами

Ключевое понимание заключается в том, что управление положением прокрутки в SwiftUI требует тщательного внимания к времени, особенно при работе с взаимодействиями клавиатуры и динамической загрузкой контента. Реализуя правильные наблюдатели за клавиатурой, используя ScrollViewReader для более детального контроля и добавляя соответствующие задержки для анимаций, вы можете решить проблемы поведения при щелчке в iOS 17 и 18.

Источники

  1. Прокрутка с привязкой в SwiftUI
  2. SwiftUI Weekly - Выпуск #224
  3. SwiftUI Weekly - Выпуск #223

Заключение

Чтобы исправить проблему с LazyVStack, который не возвращается в исходное положение после закрытия клавиатуры в iOS 17 и 18:

  1. Используйте ScrollViewReader вместо .scrollPosition для более надежного управления прокруткой
  2. Добавляйте наблюдатели за закрытием клавиатуры с соответствующими задержками для сброса положения прокрутки
  3. Реализуйте согласованное время анимации между анимациями клавиатуры и прокрутки
  4. Обеспечивайте минимальные высоты представлений для предотвращения несоответствий размера контента
  5. Тщательно тестируйте с различными длинами сообщений и взаимодействиями клавиатуры

Наиболее надежным решением является использование ScrollViewReader с наблюдателями событий клавиатуры, как показано в Решении 2 выше. Этот подход обеспечивает необходимый контроль над анимациями прокрутки и управлением положением, обеспечивая правильное поведение даже при различных размерах представлений.

Авторы
Проверено модерацией
Модерация