返回

SwiftUI 键盘遮挡布局?两种方案轻松解决!

IOS

键盘遮挡 SwiftUI 布局问题解决方案

当应用界面出现键盘时,经常会发生布局被遮挡或变形的情况。这是因为键盘的出现会影响视图的尺寸,特别是在使用 ScrollView 或其他依赖可用空间的视图时。本文将介绍一些常见的解决方法,并提供相应的代码示例,帮助你避免这类问题。

问题分析

在SwiftUI中,键盘默认会覆盖一部分屏幕空间,尤其当屏幕较小时,这个问题会更加明显。 ScrollView 的内容通常会因为键盘的出现而被压缩,或者其他固定位置的元素会被推挤变形,导致视觉体验下降。

从代码示例中可以看出,应用使用 ScrollView 来包装内容,但并未采取额外的措施来处理键盘的出现。ignoresSafeArea(.keyboard, edges: .bottom) 只是阻止了安全区域在底部对布局的干扰, 并未实际解决布局错乱问题。因此当文本输入框获得焦点,弹出键盘时,整个视图向上偏移导致底部布局被遮挡。

解决方案

方案一:使用 GeometryReader 计算可用空间

使用 GeometryReader 可以获取当前视图的大小和位置,据此动态调整布局。这是一种较为灵活的方案,可以在代码层面控制布局的适配。

原理:

通过GeometryReader 读取屏幕大小,进而得到在键盘弹起后可以被用于展示布局的区域,从而动态调整ScrollView内容的高度。这样就可以确保视图不会被键盘完全覆盖。

步骤:

  1. 使用 GeometryReader 包裹整个 ZStack,以便可以访问视图尺寸。
  2. 获取可用高度,计算ScrollView可以显示的最大高度。
  3. 动态调整VStack高度,使其小于等于计算得到的最大可用高度,从而确保内容可见。

代码示例:

struct RegisterUserView: View {
  //...省略已有代码

    var body: some View {
        NavigationView {
            GeometryReader { geometry in
              ZStack {
                   Image(backgroundImage)
                       .resizable()
                       .aspectRatio(contentMode: .fill)
                       .ignoresSafeArea()

                    ScrollView {
                        VStack(spacing: 20) {
                           Text("Neuer Account")
                              .foregroundColor(.black)
                              .font(.custom("Avenir", size: 40))
                              .fontWeight(.bold)
                          
                           VStack(alignment: .leading, spacing: 15) {
                             TextField("Dein Name", text: $username)
                                .padding()
                                .background(Color.white.opacity(0.7))
                                .cornerRadius(10)
                                .font(.custom("Avenir", size: 18))
                                .padding(.horizontal, 40)
    
                                TextField("E-Mail", text: $email)
                                .padding()
                                .background(Color.white.opacity(0.7))
                                .cornerRadius(10)
                                .font(.custom("Avenir", size: 18))
                                .padding(.horizontal, 40)
                        
                                SecureField("Passwort", text: $password)
                                .padding()
                                .background(Color.white.opacity(0.7))
                                .cornerRadius(10)
                                .font(.custom("Avenir", size: 18))
                                .padding(.horizontal, 40)
                       
                                SecureField("Passwort bestätigen", text: $confirmPassword)
                                .padding()
                                .background(Color.white.opacity(0.7))
                                .cornerRadius(10)
                                .font(.custom("Avenir", size: 18))
                                .padding(.horizontal, 40)
        
                                HStack {
                                    CheckboxView(isChecked: $isTermsAccepted)
                                    Text("Ich akzeptiere die AGBs und die Datenschutzbedingungen")
                                        .foregroundColor(.white)
                                        .font(.custom("Avenir", size: 18))
                                }
                                .padding(.horizontal, 20)
                                .padding(.vertical, 20)
                           }
                            .padding(.horizontal)

                           Button("Registrieren") {
                                // Registrierungslogik hier einfügen
                            }
                            .padding()
                            .foregroundColor(.white)
                            .background(Color.blue)
                            .cornerRadius(10)
                            .disabled(!isTermsAccepted)
                        }
                        .padding()
                    }
                }.frame(height: geometry.size.height) // 关键:固定ScrollView的内容高度
                    .ignoresSafeArea(.keyboard, edges: .bottom)

                }
            }
       .navigationBarBackButtonHidden(true)
    }
}

安全性建议: GeometryReader 依赖于父视图提供的几何尺寸,如果父视图尺寸不稳定,布局可能仍会出现问题。 使用时,务必考虑其依赖性。

方案二:利用 KeyboardReadable 扩展

创建一个能够检测键盘状态的 ViewModifier,并根据键盘状态来调整视图布局。

原理:

通过订阅 UIResponder.keyboardWillChangeFrameNotificationUIResponder.keyboardWillHideNotification 通知,动态获取键盘高度,从而动态调整界面布局,防止遮挡。

步骤:

  1. 创建 KeyboardReadable 协议,让 View 遵守, 添加一个能够表示键盘高度的属性。
  2. 实现 KeyboardReadableModifier,该修改器会监听键盘通知并更新键盘高度。
  3. 创建 keyboardHeight 环境变量,用于在其他 View 中获取键盘高度。
  4. RootView 添加environmentObject 传递。
  5. 视图内使用该扩展,通过键盘高度调整padding等,规避遮挡。

代码示例:

import SwiftUI
import Combine

protocol KeyboardReadable {
    var keyboardHeight: CGFloat { get }
}

struct KeyboardReadableModifier: ViewModifier, KeyboardReadable {
    
    @State private var keyboardHeightInternal: CGFloat = 0

     var keyboardHeight: CGFloat {
          keyboardHeightInternal
      }
    
    private var keyboardWillChangePublisher: AnyPublisher<CGRect, Never> {
            NotificationCenter.default
                .publisher(for: UIResponder.keyboardWillChangeFrameNotification)
                .compactMap { notification in
                    (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue
                }
                .eraseToAnyPublisher()
        }

    private var keyboardWillHidePublisher: AnyPublisher<Void, Never> {
        NotificationCenter.default
            .publisher(for: UIResponder.keyboardWillHideNotification)
             .map{ _ in return }
            .eraseToAnyPublisher()
       
    }
   
   
   func body(content: Content) -> some View {
        content
             .onReceive(keyboardWillChangePublisher){ rect in
                 guard let currentWindow = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {return}
                 let keyboardHeight = currentWindow.frame.height - rect.origin.y
                self.keyboardHeightInternal =  keyboardHeight
                
            }.onReceive(keyboardWillHidePublisher){_ in
                self.keyboardHeightInternal = 0
             }
    }
}
extension View{
  func keyboardReadable() -> some View{
      self.modifier(KeyboardReadableModifier())
  }
}
private struct KeyboardHeightEnvironmentKey: EnvironmentKey{
    static let defaultValue: CGFloat = 0
}

extension EnvironmentValues {
    var keyboardHeight : CGFloat {
        get { self[KeyboardHeightEnvironmentKey.self]}
        set {self[KeyboardHeightEnvironmentKey.self] = newValue }
    }
}

struct RegisterUserView: View {
     @State private var username = ""
     @State private var email = ""
     @State private var password = ""
     @State private var confirmPassword = ""
     @State private var isTermsAccepted = false
     
     let backgroundImage = "backgroundlogin"
     
     @Environment(\.keyboardHeight) var keyboardHeight: CGFloat


    var body: some View {
        NavigationView {
                ZStack {
                     Image(backgroundImage)
                          .resizable()
                            .aspectRatio(contentMode: .fill)
                            .ignoresSafeArea()
                
                 ScrollView {
                     VStack(spacing: 20) {
                         Text("Neuer Account")
                             .foregroundColor(.black)
                             .font(.custom("Avenir", size: 40))
                             .fontWeight(.bold)
                    
                        VStack(alignment: .leading, spacing: 15) {
                           TextField("Dein Name", text: $username)
                               .padding()
                              .background(Color.white.opacity(0.7))
                              .cornerRadius(10)
                              .font(.custom("Avenir", size: 18))
                              .padding(.horizontal, 40)
                        
                          TextField("E-Mail", text: $email)
                            .padding()
                            .background(Color.white.opacity(0.7))
                            .cornerRadius(10)
                            .font(.custom("Avenir", size: 18))
                            .padding(.horizontal, 40)
                        
                        SecureField("Passwort", text: $password)
                           .padding()
                           .background(Color.white.opacity(0.7))
                           .cornerRadius(10)
                           .font(.custom("Avenir", size: 18))
                           .padding(.horizontal, 40)
                      
                      SecureField("Passwort bestätigen", text: $confirmPassword)
                         .padding()
                           .background(Color.white.opacity(0.7))
                            .cornerRadius(10)
                            .font(.custom("Avenir", size: 18))
                            .padding(.horizontal, 40)
                        
                      HStack {
                        CheckboxView(isChecked: $isTermsAccepted)
                            Text("Ich akzeptiere die AGBs und die Datenschutzbedingungen")
                            .foregroundColor(.white)
                           .font(.custom("Avenir", size: 18))
                     }
                     .padding(.horizontal, 20)
                     .padding(.vertical, 20)
                   }
                    .padding(.horizontal)

                       Button("Registrieren") {
                            // Registrierungslogik hier einfügen
                        }
                        .padding()
                        .foregroundColor(.white)
                       .background(Color.blue)
                      .cornerRadius(10)
                       .disabled(!isTermsAccepted)
                   }
                   .padding()
                }
               .ignoresSafeArea(.keyboard, edges: .bottom)
              }.padding(.bottom, keyboardHeight)
             }.keyboardReadable()
        .navigationBarBackButtonHidden(true) // Behält das Ausblenden des standardmäßigen "Zurück"-Buttons bei
    }
}

struct ContentView: View {
     @State var keyboardHeight: CGFloat = 0
      var body: some View {
             RegisterUserView()
             .environment(\.keyboardHeight, keyboardHeight)
     }
 }


struct CheckboxView: View {
    @Binding var isChecked: Bool
    
    var body: some View {
        Image(systemName: isChecked ? "checkmark.square.fill" : "square")
            .foregroundColor(isChecked ? .blue : .gray)
            .onTapGesture {
               self.isChecked.toggle()
            }
       }
}

struct RegisterUserView_Previews: PreviewProvider {
    static var previews: some View {
         ContentView()
        }
}

安全性建议: 注意 keyboardHeight 值有可能因为一些边界情况(例如键盘切换等)而出现短暂的错误,确保你的逻辑能够处理这些情况,必要时使用debounce操作符。

总结

针对键盘遮挡SwiftUI布局的问题,存在多种解决方式。上述的 GeometryReader 方法能够灵活地根据可用空间调整视图, KeyboardReadable 扩展可以通过动态计算键盘高度,在布局调整方面更加灵活。选择合适的方案需要根据项目的具体需求和复杂程度进行考量。 此外, 及时关注 SwiftUI 的更新和社区实践,能够帮助我们获取更有效和适应性强的布局解决方案。