SwiftUI로 로그인 화면이나 채팅 화면같이 키보드 입력이 필요한 화면을 개발하다 보면,
키보드가 올라올 때 이를 감지하고 키보드가 화면을 가리지 않도록 처리를 해주는 일이 많다.
키보드가 화면을 가리지 않도록 구현하는 방법은 여러 가지가 있는 것으로 알고 있는데,
나는 그중에서도 ScrollView
와 ScrollViewReader
를 이용해서 이를 구현해 보았다.
@Namespace
선언
SwiftUI의 @Namespace
는 View
처럼 속성을 갖고 있는 객체에 ID를 부여하고, 부여한 ID를 통해 접근을 할 수 있게 만들어주는 Property Wrapper
이다.
다시 말하면,
@Namespace
를 선언하고, 접근하려는 View
객체에 ID를 부여해 주면 된다는 말이다.
아래는 간단한 예시이다.
import SwiftUI
struct NameSpaceExam: View {
@Namespace var buttonId
var body: some View {
VStack {
Button {
// action
} label: {
Text("Button One")
}
.id(buttonId) // ID 부여
}
}
}
간단하게 Button View
를 선언했고, 그 Button에 .id(\_ id:)
속성을 선언해서 버튼에 ID를 부여해준 것이다.
ScrollViewReader
/ ScrollView
활용
이제, 버튼이 눌리거나 키보드가 올라왔을 때 등 액션이 실행될 때, 부여한 ID를 이용해서 화면을 Scroll 시키면 된다.
ScrollViewReader
의 ScrollViewProxy.scrollTo(\_ id:)
를 이용해서, 해당 ID를 부여받은 View
가 위치한 곳까지 스크롤을 시킬 수 있다.
아래 예제는, Apple Docs에서 가져온 공식 예제이다.
https://developer.apple.com/documentation/swiftui/scrollviewreader
import SwiftUI
struct ContentView: View {
@Namespace var topID
@Namespace var bottomID
var body: some View {
ScrollViewReader { proxy in
ScrollView {
Button("Scroll to Bottom") {
withAnimation {
proxy.scrollTo(bottomID)
}
}
.id(topID)
VStack(spacing: 0) {
ForEach(0..<100) { i in
color(fraction: Double(i) / 100)
.frame(height: 32)
}
}
Button("Top") {
withAnimation {
proxy.scrollTo(topID)
}
}
.id(bottomID)
}
}
}
}
func color(fraction: Double) -> Color {
Color(red: fraction, green: 1 - fraction, blue: 0.5)
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
키보드 표출 감지 (TextField)
이제, 화면에 키보드가 표출되는 것을 감지하여 화면을 최하단으로 스크롤한다면, 자연스럽게 키보드가 화면을 가리는 것을 방지할 수 있게 된다.
UIKit
기반 앱과 같은 방식으로, NotificationCenter
에 Keyboard WillShow/WillHide Observer를 등록해서 키보드 표출 감지 기능을 구현할 수 있다.
SwiftUI
의 View
는 struct
타입으로 만드니, @objc
메서드를 바로 선언해서 사용하는 게 안된다.
그래서 Observer
를 등록해서 사용할 수 있게 도와주는 Class를 만들어서 사용했다.
class KeyboardObserver: ObservableObject {
@Published public var isKeyboardShow = false
func addObservers() {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardUp), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDown), name: UIResponder.keyboardWillHideNotification, object: nil)
}
func removeObservers() {
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
}
@objc func keyboardUp(notification:NSNotification) {
isKeyboardShow = true
}
@objc func keyboardDown(notification: NSNotification) {
isKeyboardShow = false
}
}
그 다음은 만들고 있는 View
에서
- 최상단에 위치하는
Image
와 최하단Button
에 부여할@Namespace
를 각각 선언하고, - 위에서 작성한
KeyboardObserver
클래스도@StateObject
(또는@ObservedObject
) 타입으로 선언해 준다.
struct LoginExamView: View {
@Namespace var imageId
@Namespace var logInButtonId
@StateObject var keyboardObserver = KeyboardObserver()
var body: some View {
...
...
그리고 View
의 각 최상단/최하단에 위치하는 View 요소에 ID를 부여해 준다.
var body: some View {
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
Image(systemName: "apple.logo")
.resizable()
.scaledToFill()
.padding(.horizontal, 70)
.padding(.vertical, 30)
.id(topId) // ID 부여
...
...
VStack {
Button {
// action
} label: {
Text("Sign in")
.foregroundColor(.black)
.bold()
}
.frame(maxWidth: 300)
.frame(height: 50)
.background(RoundedRectangle(cornerRadius: 10).fill(.green))
.buttonStyle(.plain)
}
.id(logInButtonId) // ID 부여
}
}
}
}
그리고 View
가 표시될 때 Observer
를 등록하고, 사라질 때 삭제되도록 처리해 준다.
다만, SwiftUI
에서는 View
의 라이프 사이클을 관리할 때 viewDidLoad()
/ viewDidDisapear()
가 아니라 .onAppear
/ .onDisappear
를 사용한다.
var body: some View {
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
...
...
}
}
.onAppear { // when appear
keyboardObserver.addObservers()
}
.onDisappear { // when Disappear
keyboardObserver.removeObservers()
}
}
}
이제 마지막으로, 표출 여부를 알려주는 flag 값을 .onChange()
로 감시하고 있다가 변경이 일어나면 scrollTo()
로 움직여주면 끝이다.
var body: some View {
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
...
...
}
.onChange(of: keyboardObserver.isKeyboardShow) { isShowKeyboard in
if isShowKeyboard == true {
withAnimation {
if #available(iOS 16.0, *) {
proxy.scrollTo(logInButtonId, anchor: .top)
} else {
proxy.scrollTo(logInButtonId)
}
}
} else {
withAnimation {
if #available(iOS 16.0, *) {
proxy.scrollTo(topId, anchor: .top)
} else {
proxy.scrollTo(topId)
}
}
}
}
}
.onAppear { // when appear
keyboardObserver.addObservers()
}
.onDisappear { // when Disappear
keyboardObserver.removeObservers()
}
}
}
주의점
- iOS 16.0 이상 버전부터는,
scrollTo()
를 호출할 때anchor:
파라미터를 함께 넘겨주어야 정상 동작한다.
iOS16 버전부터 갑자기 기능이 먹통이 돼서 해결방법 찾으려고 온갖 삽질을 다 했었는데, 아주 심플하게 해결할 수 있는 버그인걸 겨우 발견했다.
기본값이 nil
로 잡혀있는 걸 보니, iOS16으로 올라가면서 내부적으로 guard
처리가 없던 게 생긴 게 아닐까..라는 추측을 해본다.
좀 어이가 없는 게, 하위 버전에서는 오히려 anchor
값을 할당해 주면 스크롤이 튀는(?)것처럼 이상하게 동작 하는 경우가 발생한다.
그래서 #available(iOS 16.0, \*)
조건을 추가해서 이를 대응했다.
최종적으로 아래처럼 동작한다.
전체 코드
import SwiftUI
struct LoginExamView: View {
@Namespace var logInButtonId
@Namespace var topId
@StateObject var keyboardObserver = KeyboardObserver()
@State var email = ""
@State var password = ""
var body: some View {
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
Image(systemName: "apple.logo")
.resizable()
.scaledToFill()
.padding(.horizontal, 70)
.padding(.vertical, 30)
.id(topId)
HStack {
Text("Log in")
.font(.largeTitle)
.bold()
.padding(.leading, 15)
Spacer()
}
VStack(spacing: 10) {
TextField("email", text: $email)
.textFieldStyle(.roundedBorder)
.textContentType(.emailAddress)
.autocapitalization(.none)
.autocorrectionDisabled()
.padding(.horizontal, 20)
SecureField("password", text: $password)
.textFieldStyle(.roundedBorder)
.textContentType(.password)
.autocorrectionDisabled()
.padding(.horizontal, 20)
}
.padding(.bottom, 20)
// MARK: Log in button
VStack {
Button {
// action
} label: {
Text("Sign in")
.foregroundColor(.black)
.bold()
}
.frame(maxWidth: 300)
.frame(height: 50)
.background(RoundedRectangle(cornerRadius: 10).fill(.green))
.buttonStyle(.plain)
}
.id(logInButtonId)
}
.onChange(of: keyboardObserver.isKeyboardShow) { isShowKeyboard in
if isShowKeyboard == true {
withAnimation {
if #available(iOS 16.0, *) {
proxy.scrollTo(logInButtonId, anchor: .top)
} else {
proxy.scrollTo(logInButtonId)
}
}
} else {
withAnimation {
if #available(iOS 16.0, *) {
proxy.scrollTo(topId, anchor: .top)
} else {
proxy.scrollTo(topId)
}
}
}
}
}
.onAppear {
keyboardObserver.addObservers()
}
.onDisappear {
keyboardObserver.removeObservers()
}
}
}
struct LoginExamView_Previews: PreviewProvider {
static var previews: some View {
LoginExamView()
}
}
class KeyboardObserver: ObservableObject {
@Published public var isKeyboardShow = false
func addObservers() {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardUp), name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDown), name: UIResponder.keyboardWillHideNotification, object: nil)
}
func removeObservers() {
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
}
@objc func keyboardUp(notification:NSNotification) {
isKeyboardShow = true
}
@objc func keyboardDown(notification: NSNotification) {
isKeyboardShow = false
}
}
'iOS(macOS) > SwiftUI' 카테고리의 다른 글
[SwiftUI/iOS] 이미지 파일로 Launch Screen 만들기 (0) | 2023.08.08 |
---|---|
[iOS/SwiftUI] Navigation - Back Button 커스텀 하기 (0) | 2023.04.26 |
[SwiftUI] 경고 해결: Info.plist contained no UIScene configuration dictionary (looking for configuration named "(no name)") (0) | 2023.03.02 |
[SwiftUI] Alert 여러개 사용할 때 (3) | 2023.02.21 |
[SwiftUI/Xcode] Preview 화면 숨기기/나타내기 (0) | 2023.02.09 |