Переместить TextField вверх, когда клавиатура появилась с помощью SwiftUI? : iOS

У меня есть семь TextField внутри моего основного ContentView. Когда пользователь открывает клавиатуру, некоторые из TextField скрываются под рамкой клавиатуры. Поэтому я хочу переместить все TextField вверх соответственно, когда клавиатура появилась.

Я использовал приведенный ниже код для добавления TextField на экран.

struct ContentView : View {
    @State var textfieldText: String = ""

    var body: some View {
            VStack {
                TextField($textfieldText, placeholder: Text("TextField1"))
                TextField($textfieldText, placeholder: Text("TextField2"))
                TextField($textfieldText, placeholder: Text("TextField3"))
                TextField($textfieldText, placeholder: Text("TextField4"))
                TextField($textfieldText, placeholder: Text("TextField5"))
                TextField($textfieldText, placeholder: Text("TextField6"))
                TextField($textfieldText, placeholder: Text("TextField6"))
                TextField($textfieldText, placeholder: Text("TextField7"))
            }
    }
}

Выход:

Output

Ответ 1

Код обновлен для Xcode, бета-версия 7.

Вам не нужно заполнение, ScrollViews или списки для достижения этой цели. Хотя это решение будет хорошо с ними играть. Я привожу здесь два примера.

Первый перемещает все текстовое поле вверх, если для любого из них появляется клавиатура. Но только при необходимости. Если клавиатура не скрывает текстовые поля, они не будут двигаться.

Во втором примере представление только перемещается достаточно, чтобы избежать скрытия активного текстового поля.

Оба примера используют один и тот же общий код, найденный в конце: GeometryGetter и KeyboardGuardian

Первый пример (показать все текстовые поля)

When the keyboard is opened, the 3 textfields are moved up enough to keep then all visible

struct ContentView: View {
    @ObservedObject private var kGuardian = KeyboardGuardian(textFieldCount: 1)
    @State private var name = Array<String>.init(repeating: "", count: 3)

    var body: some View {

        VStack {
            Group {
                Text("Some filler text").font(.largeTitle)
                Text("Some filler text").font(.largeTitle)
            }

            TextField("enter text #1", text: $name[0])
                .textFieldStyle(RoundedBorderTextFieldStyle())

            TextField("enter text #2", text: $name[1])
                .textFieldStyle(RoundedBorderTextFieldStyle())

            TextField("enter text #3", text: $name[2])
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .background(GeometryGetter(rect: $kGuardian.rects[0]))

        }.offset(y: kGuardian.slide).animation(.easeInOut(duration: 1.0))
    }

}

Второй пример (показывать только активное поле)

When each text field is clicked, the view is only moved up enough to make the clicked text field visible.

struct ContentView: View {
    @ObservedObject private var kGuardian = KeyboardGuardian(textFieldCount: 3)
    @State private var name = Array<String>.init(repeating: "", count: 3)

    var body: some View {

        VStack {
            Group {
                Text("Some filler text").font(.largeTitle)
                Text("Some filler text").font(.largeTitle)
            }

            TextField("text #1", text: $name[0], onEditingChanged: { if $0 { self.kGuardian.showField = 0 } })
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .background(GeometryGetter(rect: $kGuardian.rects[0]))

            TextField("text #2", text: $name[1], onEditingChanged: { if $0 { self.kGuardian.showField = 1 } })
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .background(GeometryGetter(rect: $kGuardian.rects[1]))

            TextField("text #3", text: $name[2], onEditingChanged: { if $0 { self.kGuardian.showField = 2 } })
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .background(GeometryGetter(rect: $kGuardian.rects[2]))

            }.offset(y: kGuardian.slide).animation(.easeInOut(duration: 1.0))
    }.onAppear { self.kGuardian.addObserver() } 
.onDisappear { self.kGuardian.removeObserver() }

}

GeometryGetter

Это представление, которое поглощает размер и положение родительского представления. Для этого он вызывается внутри модификатора .background. Это очень мощный модификатор, а не просто способ украсить фон представления. При передаче представления в .background(MyView()) MyView получает измененное представление в качестве родительского. Использование GeometryReader - это то, что позволяет представлению узнать геометрию родителя.

Например: Text("hello").background(GeometryGetter(rect: $bounds)) будет заполнять переменные границы размером и положением текстового представления и используя глобальное координатное пространство.

struct GeometryGetter: View {
    @Binding var rect: CGRect

    var body: some View {
        GeometryReader { geometry in
            Group { () -> AnyView in
                DispatchQueue.main.async {
                    self.rect = geometry.frame(in: .global)
                }

                return AnyView(Color.clear)
            }
        }
    }
}

Обновление Я добавил DispatchQueue.main.async, чтобы избежать возможности изменения состояния представления во время его визуализации. ***

KeyboardGuardian

Цель KeyboardGuardian - отслеживать события показа/скрытия клавиатуры и вычислять, сколько места необходимо сместить.

Обновление: Я изменил KeyboardGuardian, чтобы обновить слайд, когда пользователь перемещается из одного поля в другое

import SwiftUI
import Combine

final class KeyboardGuardian: ObservableObject {
    public var rects: Array<CGRect>
    public var keyboardRect: CGRect = CGRect()

    // keyboardWillShow notification may be posted repeatedly,
    // this flag makes sure we only act once per keyboard appearance
    public var keyboardIsHidden = true

    @Published var slide: CGFloat = 0

    var showField: Int = 0 {
        didSet {
            updateSlide()
        }
    }

    init(textFieldCount: Int) {
        self.rects = Array<CGRect>(repeating: CGRect(), count: textFieldCount)

    }

    func addObserver() {
NotificationCenter.default.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(keyBoardDidHide(notification:)), name: UIResponder.keyboardDidHideNotification, object: nil)
}

func removeObserver() {
 NotificationCenter.default.removeObserver(self)
}

    deinit {
        NotificationCenter.default.removeObserver(self)
    }



    @objc func keyBoardWillShow(notification: Notification) {
        if keyboardIsHidden {
            keyboardIsHidden = false
            if let rect = notification.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect {
                keyboardRect = rect
                updateSlide()
            }
        }
    }

    @objc func keyBoardDidHide(notification: Notification) {
        keyboardIsHidden = true
        updateSlide()
    }

    func updateSlide() {
        if keyboardIsHidden {
            slide = 0
        } else {
            let tfRect = self.rects[self.showField]
            let diff = keyboardRect.minY - tfRect.maxY

            if diff > 0 {
                slide += diff
            } else {
                slide += min(diff, 0)
            }

        }
    }
}

Ответ 2

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

Это довольно просто. Мы создаем издателей для событий отображения/скрытия клавиатуры, а затем подписываемся на них, используя onReceive. Мы используем результат этого для создания прямоangularьника размером с клавиатуру за клавиатурой.

struct KeyboardHost<Content: View>: View {
    let view: Content

    @State private var keyboardHeight: CGFloat = 0

    private let showPublisher = NotificationCenter.Publisher.init(
        center: .default,
        name: UIResponder.keyboardWillShowNotification
    ).map { (notification) -> CGFloat in
        if let rect = notification.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect {
            return rect.size.height
        } else {
            return 0
        }
    }

    private let hidePublisher = NotificationCenter.Publisher.init(
        center: .default,
        name: UIResponder.keyboardWillHideNotification
    ).map {_ -> CGFloat in 0}

    // Like HStack or VStack, the only parameter is the view that this view should layout.
    // (It takes one view rather than the multiple views that Stacks can take)
    init(@ViewBuilder content: () -> Content) {
        view = content()
    }

    var body: some View {
        VStack {
            view
            Rectangle()
                .frame(height: keyboardHeight)
                .animation(.default)
                .foregroundColor(.clear)
        }.onReceive(showPublisher.merge(with: hidePublisher)) { (height) in
            self.keyboardHeight = height
        }
    }
}

Затем вы можете использовать вид следующим образом:

var body: some View {
    KeyboardHost {
        viewIncludingKeyboard()
    }
}

Чтобы переместить содержимое представления вверх, а не уменьшить его, в view можно добавить заполнение или смещение, а не помещать его в VStack с прямоangularьником.

Ответ 3

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

Чтобы получить размер клавиатуры, вам нужно будет использовать NotificationCenter для регистрации события клавиатуры. Для этого вы можете использовать собственный класс:

import SwiftUI
import Combine

final class KeyboardResponder: BindableObject {
    let didChange = PassthroughSubject<CGFloat, Never>()

    private var _center: NotificationCenter
    private(set) var currentHeight: CGFloat = 0 {
        didSet {
            didChange.send(currentHeight)
        }
    }

    init(center: NotificationCenter = .default) {
        _center = center
        _center.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
        _center.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
    }

    deinit {
        _center.removeObserver(self)
    }

    @objc func keyBoardWillShow(notification: Notification) {
        print("keyboard will show")
        if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue {
            currentHeight = keyboardSize.height
        }
    }

    @objc func keyBoardWillHide(notification: Notification) {
        print("keyboard will hide")
        currentHeight = 0
    }
}

Соответствие BindableObject позволит вам использовать этот класс в качестве State и запустить обновление представления. При необходимости посмотрите учебник для BindableObject: учебник SwiftUI

Когда вы получите это, вам нужно настроить ScrollView, чтобы уменьшить его размер при появлении клавиатуры. Для удобства я обернул этот ScrollView в какой-то компонент:

struct KeyboardScrollView<Content: View>: View {
    @State var keyboard = KeyboardResponder()
    private var content: Content

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    var body: some View {
        ScrollView {
            VStack {
                content
            }
        }
        .padding(.bottom, keyboard.currentHeight)
    }
}

Все, что вам нужно сделать сейчас, это встроить ваш контент в пользовательский ScrollView.

struct ContentView : View {
    @State var textfieldText: String = ""

    var body: some View {
        KeyboardScrollView {
            ForEach(0...10) { index in
                TextField(self.$textfieldText, placeholder: Text("TextField\(index)")) {
                    // Hide keyboard when uses tap return button on keyboard.
                    self.endEditing(true)
                }
            }
        }
    }

    private func endEditing(_ force: Bool) {
        UIApplication.shared.keyWindow?.endEditing(true)
    }
}

Edit: Поведение прокрутки действительно странно, когда клавиатура прячется. Возможно, использование анимации для обновления отступов исправит это, или вам следует рассмотреть возможность использования чего-то другого, кроме padding, для настройки размера представления прокрутки.

Ответ 4

Чтобы создать решение @rraphael, я преобразовал его для использования в сегодняшней поддержке xcode11 swiftUI.

import SwiftUI

final class KeyboardResponder: ObservableObject {
    private var notificationCenter: NotificationCenter
    @Published private(set) var currentHeight: CGFloat = 0

    init(center: NotificationCenter = .default) {
        notificationCenter = center
        notificationCenter.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
        notificationCenter.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
    }

    deinit {
        notificationCenter.removeObserver(self)
    }

    @objc func keyBoardWillShow(notification: Notification) {
        if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue {
            currentHeight = max(keyboardSize.height, 340.0)
        }
    }

    @objc func keyBoardWillHide(notification: Notification) {
        currentHeight = 0
    }
}

Использование:

struct ContentView: View {
    @ObservedObject private var keyboard = KeyboardResponder()
    @State private var textFieldInput: String = ""

    var body: some View {
        VStack {
            HStack {
                TextField("uMessage", text: $textFieldInput)
            }
        }.padding().padding(.bottom, keyboard.currentHeight)
    }
}

Опубликованный currentHeight вызовет повторный рендеринг пользовательского интерфейса и переместит ваш TextField вверх, когда клавиатура отобразится, и вернется вниз, когда закроется. Однако я не использовал ScrollView.

Ответ 5

В качестве отправной точки я использовал ответ бенджамина Киндла, но у меня было несколько вопросов, которые я хотел решить.

  1. Большинство ответов здесь не касаются смены рамки клавиатуры, поэтому они ломаются, если пользователь поворачивает устройство с помощью клавиатуры на экране. Добавление keyboardWillChangeFrameNotification в список обработанных уведомлений адресовывает это.
  2. Я не хотел, чтобы несколько издателей имели похожие, но разные закрытия карт, поэтому я связал все три клавиатурных уведомления в одном издателе. По общему признанию, это длинная цепь, но каждый шаг довольно прост.
  3. Я предоставил функцию init, которая принимает @ViewBuilder, так что вы можете использовать представление KeyboardHost, как и любой другой вид, и просто передавать свой контент в конце замыкания, в отличие от передачи представления контента в качестве параметра для init.
  4. Как предложили Tae и fdelafuente в комментариях, я поменял Rectangle для настройки нижнего отступа.
  5. Вместо использования жестко закодированной строки "UIKeyboardFrameEndUserInfoKey" я хотел использовать строки, представленные в UIWindow, как UIWindow.keyboardFrameEndUserInfoKey.

Вытащив все это вместе, я имею:

struct KeyboardHost<Content>: View  where Content: View {
    var content: Content

    /// The current height of the keyboard rect.
    @State private var keyboardHeight = CGFloat(0)

    /// A publisher that combines all of the relevant keyboard changing notifications and maps them into a 'CGFloat' representing the new height of the
    /// keyboard rect.
    private let keyboardChangePublisher = NotificationCenter.Publisher(center: .default,
                                                                       name: UIResponder.keyboardWillShowNotification)
        .merge(with: NotificationCenter.Publisher(center: .default,
                                                  name: UIResponder.keyboardWillChangeFrameNotification))
        .merge(with: NotificationCenter.Publisher(center: .default,
                                                  name: UIResponder.keyboardWillHideNotification)
            // But we don't want to pass the keyboard rect from keyboardWillHide, so strip the userInfo out before
            // passing the notification on.
            .map { Notification(name: $0.name, object: $0.object, userInfo: nil) })
        // Now map the merged notification stream into a height value.
        .map { ($0.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? CGRect ?? .zero).size.height }
        // If you want to debug the notifications, swap this in for the final map call above.
//        .map { (note) -> CGFloat in
//            let height = (note.userInfo?[UIWindow.keyboardFrameEndUserInfoKey] as? CGRect ?? .zero).size.height
//
//            print("Received \(note.name.rawValue) with height \(height)")
//            return height
//    }

    var body: some View {
        content
            .onReceive(keyboardChangePublisher) { self.keyboardHeight = $0 }
            .padding(.bottom, keyboardHeight)
            .animation(.default)
    }

    init(@ViewBuilder _ content: @escaping () -> Content) {
        self.content = content()
    }
}

struct KeyboardHost_Previews: PreviewProvider {
    static var previews: some View {
        KeyboardHost {
            TextField("TextField", text: .constant("Preview text field"))
        }
    }
}

Ответ 6

Самый элегантный ответ, который мне удалось найти, похож на решение Рафаэля. Создайте класс для прослушивания событий клавиатуры. Вместо того чтобы использовать размер клавиатуры для изменения отступов, верните отрицательное значение размера клавиатуры и используйте модификатор .offset(y :), чтобы отрегулировать внешнее смещение большинства контейнеров вида. Он оживляет достаточно хорошо и работает с любым видом.

Ответ 7

Решение Kontiki очень приятно. Тем не менее, кажется, прокрутить кадр вниз в Xcode 11 бета 4.

Ответ 8

Это адаптировано из того, что @kontiki построил. У меня он работает в приложении под бета 8/GM seed, где нуждающаяся в прокрутке область является частью формы внутри NavigationView. Здесь KeyboardGuardian:

//
//  KeyboardGuardian.swift
//
//  https://stackoverflow.com/info/56491881/move-textfield-up-when-thekeyboard-has-appeared-by-using-swiftui-ios
//

import SwiftUI
import Combine

/// The purpose of KeyboardGuardian, is to keep track of keyboard show/hide events and
/// calculate how much space the view needs to be shifted.
final class KeyboardGuardian: ObservableObject {
    let objectWillChange = ObservableObjectPublisher() // PassthroughSubject<Void, Never>()

    public var rects: Array<CGRect>
    public var keyboardRect: CGRect = CGRect()

    // keyboardWillShow notification may be posted repeatedly,
    // this flag makes sure we only act once per keyboard appearance
    private var keyboardIsHidden = true

    var slide: CGFloat = 0 {
        didSet {
            objectWillChange.send()
        }
    }

    public var showField: Int = 0 {
        didSet {
            updateSlide()
        }
    }

    init(textFieldCount: Int) {
        self.rects = Array<CGRect>(repeating: CGRect(), count: textFieldCount)

        NotificationCenter.default.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(keyBoardDidHide(notification:)), name: UIResponder.keyboardDidHideNotification, object: nil)

    }

    @objc func keyBoardWillShow(notification: Notification) {
        if keyboardIsHidden {
            keyboardIsHidden = false
            if let rect = notification.userInfo?["UIKeyboardFrameEndUserInfoKey"] as? CGRect {
                keyboardRect = rect
                updateSlide()
            }
        }
    }

    @objc func keyBoardDidHide(notification: Notification) {
        keyboardIsHidden = true
        updateSlide()
    }

    func updateSlide() {
        if keyboardIsHidden {
            slide = 0
        } else {
            slide = -keyboardRect.size.height
        }
    }
}

Затем я использовал enum для отслеживания слотов в массиве rects и общего числа:

enum KeyboardSlots: Int {
    case kLogPath
    case kLogThreshold
    case kDisplayClip
    case kPingInterval
    case count
}

KeyboardSlots.count.rawValue - необходимая емкость массива; другие, как rawValue, дают соответствующий индекс, который вы будете использовать для вызовов .background(GeometryGetter).

С этой настройкой представления попадают в KeyboardGuardian с этим:

@ObservedObject private var kGuardian = KeyboardGuardian(textFieldCount: SettingsFormBody.KeyboardSlots.count.rawValue)

Фактическое движение выглядит так:

.offset(y: kGuardian.slide).animation(.easeInOut(duration: 1))

прикреплен к представлению. В моем случае он прикреплен ко всему NavigationView, поэтому вся сборка сдвигается вверх по мере появления клавиатуры.

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

struct DismissingKeyboard: ViewModifier {
    func body(content: Content) -> some View {
        content
            .onTapGesture {
                let keyWindow = UIApplication.shared.connectedScenes
                        .filter({$0.activationState == .foregroundActive})
                        .map({$0 as? UIWindowScene})
                        .compactMap({$0})
                        .first?.windows
                        .filter({$0.isKeyWindow}).first
                keyWindow?.endEditing(true)                    
        }
    }
}

Вы прикрепляете его к представлению как

.modifier(DismissingKeyboard())

Некоторым представлениям (например, сборщикам) не нравится прикреплять их, поэтому вам может понадобиться быть более детальным в том, как прикреплять модификатор, а не просто шлепать его по внешнему виду.

Большое спасибо @kontiki за тяжелую работу. Вам все еще понадобится его GeometryGetter выше (нет, я не выполнил работу по его преобразованию в настройки), как он иллюстрирует в своих примерах.