SwiftUI 키보드 닫기 문제


현재 SwiftUI를 이용하여 앱을 개발하고 있다. 현재 구현한 화면은 아래와 같다.

D17D9239-DEE8-4AE2-9259-B4D7DE65A1C4_1_105_c

위 화면을 보앗듯이 폼 형태의 화면이다. textfield를 이용하여 구현하였고, 입력은 아이폰 내의 키보드를 이용하여 입력을 받도록 설계되어 있다. 여기서 문제가 하나 있다. textfield를 클릭하면 키보드가 등장하는데 키보드를 닫을 방법이 따로 존재하지 않는다. 기본 키보드에서는 return 버튼이 있어서 이것을 터치하여 키보드를 닫으면 된다. 하지만 숫자패드 같은 경우에는 얘기가 달라진다.

E2ED999F-8735-4B7C-A56F-6675ADE8460F_1_105_c

위 사진을 보면 숫자패드 오른쪽 위 부분에 닫기 버튼이 존재하는 것을 볼 수가 있을 것이다. 이부분은 기본적으로 제공되는 부분이 아니기 때문에 따로 구현을 해주어야 한다.

키보드 닫는 방법

키보드를 닫는 방법을 찾아보는 도중 총 3가지를 알아냈다.

  1. 위 사진과 같이 닫기 버튼을 구현하는 방법
  2. 키보드 밖 화면을 터치하여 닫는 방식
  3. 키보드 밖 화면을 스크롤하여 닫는 방식

첫번째 부터 차례대로 구현을 해보도록 하겠다.

키보드에 닫기 버튼을 추가하기

이 방법을 구현하는 데는 그리 오랜시간이 걸리지 않았다. 왜냐? 라이브러리가 존재하기 때문이다.

여기 를 이용하면 간단하게 구현이 가능하다. 위에 첨부한 라이브러리를 사용하려면 xcode에서 프로젝트 파일을 열어서 swift package 메뉴를 클릭하여 해당 주소로 의존성을 추가하면 된다. 추가하는 방법은 너무나 간단하기 때문에 생략하도록 하겠다.

그런데도 모르겠다.. 하는 사람은 여기를 참조하면 된다.

패키지를 설치 한 후, 해당 라이브러리를 사용하기 위해서는 import를 시켜주어야 한다.

코드 상단에 “import KeyboardToolbar”를 추가해주면 사용할 수 있다.

첫번째 사진에 대한 화면을 구현하는 모든 코드를 첨부하였다.

import SwiftUI
import KeyboardToolbar

let toolbarItems: [KeyboardToolbarItem] = [
    .dismissKeyboard
]
struct CreateView: View {
    @State var title: String = ""
    @State var password: String = ""
    @State var verboseLeft: String = ""
    @State var verboseRight: String = ""
    @State var minuties: String = ""
    
    let buttons = ["섬멸전", "폭탄전", "스파이전"]
    @State public var buttonSelected: Int?
    @State private var showingAlert = false
    
    var body: some View {
        GeometryReader { gp in
            VStack(alignment:.center, spacing: 5) {
                
                TextField("방 제목", text: self.$title)
                    .padding()
                    .frame(width: gp.size.width * 0.9)
                
                Divider()
                    .frame(width: gp.size.width * 0.9)
                
                SecureField("비밀번호", text: self.$password)
                    .padding()
                    .frame(width: gp.size.width * 0.9)
                    .keyboardType(.numberPad)
                
                Divider()
                    .frame(width: gp.size.width * 0.9)
                
                HStack() {
                    TextField("5", text: self.$verboseLeft)
                        .keyboardType(.numberPad)
                        .padding()
                        .frame(width: gp.size.width * 0.2, alignment: .center)
                        .multilineTextAlignment(.center)
                    Text("VS")
                        .padding()
                    TextField("5", text: self.$verboseRight)
                        .keyboardType(.numberPad)
                        .padding()
                        .frame(width: gp.size.width * 0.2, alignment: .center)
                        .multilineTextAlignment(.center)
                }
                
                Divider()
                    .frame(width: gp.size.width * 0.9)
                
                HStack(){
                    TextField("게임시간(분)", text: self.$minuties)
                        .keyboardType(.numberPad)
                        .padding()
                        .multilineTextAlignment(.trailing)
                        .frame(width: gp.size.width * 0.7)
                    
                    Text("분")
                        .padding()
                        .frame(width: gp.size.width * 0.2)
                }
                
                Divider()
                    .frame(width: gp.size.width * 0.9)
                
                Group(){
                    HStack(){
                        Spacer()
                        Text("게임모드")
                            .padding()
                        Spacer()
                    }
                    HStack() {
                        ForEach(0..<buttons.count) { button in
                            Button(action: {
                                self.buttonSelected = button
                            }) {
                                VStack(){
                                    Image(self.buttonSelected == button ? self.buttons[button]+"-selected" : self.buttons[button])
                                        .resizable()
                                        .aspectRatio(contentMode: .fit)
                                        .frame(width: 80)
                                    Text("\(self.buttons[button])")
                                        .foregroundColor(self.buttonSelected == button ? Color.blue : Color.gray)
                                }
                                .frame(width: gp.size.width * 0.25, height: gp.size.width * 0.35)
                                .clipShape(Rectangle())
                                .border(self.buttonSelected == button ? Color.blue : Color.gray, width: 1)
                            }
                        }
                    }
                    
                }
                
                
                Button(action: {
                    print("Button action")
                    if self.create_validation() == false {
                        self.showingAlert = true
                    }
                }) {
                    HStack {
                        Text("생성하기")
                            .font(.title2)
                            .foregroundColor(Color.white)
                    }
                    .frame(width: gp.size.width * 0.6, height: gp.size.height * 0.1)
                    .background(Color.blue)
                    .cornerRadius(10)
                }
                .padding(.top, 30)
                .alert(isPresented: $showingAlert) {
                    Alert(title: Text("입력"), message: Text("모든 항목을 입력해주세요."), dismissButton: .default(Text("확인")))
                }
                
            }.listRowInsets(EdgeInsets())
            
        }
        .keyboardToolbar(toolbarItems)
        
    }
    
    func create_validation() -> Bool {
        if self.title == "" {
            return false
        }
        else if self.password == "" {
            return false
        }
        else if self.verboseLeft == "" {
            return false
        }
        else if self.verboseRight == "" {
            return false
        }
        else if self.minuties == "" {
            return false
        }
        else if self.buttonSelected == nil {
            return false
        }
        
        return true
    }
}

위와 같이 코드를 입력하면 첫 번째 사진에 있는 것처럼 뷰가 구현이 된다.

추가로 키보드 툴바 버튼을 커스텀하고자 한다면

let toolbarItems: [KeyboardToolbarItem] = [
    .dismissKeyboard
]

여기에 추가해줌으로 써 버튼을 추가 또는 삭제를 할 수가 있다. 나는 현재 키보드 닫기버튼만 필요하므로 하나만 추가한 상태이다. 그리고 적용을 해주기위해

.keyboardToolbar(toolbarItems)

위 코드를 뷰의 젤 첫번째 부분에 추가하여 주면 적용이 완료 된다.

그러면 아래 사진과 같이 구현이 완료된다.

58E2D3DD-B724-49ED-A197-ED6802D7C17B

위 움짤에서는 뚝뚝 끊겨 보이는데 실제로는 부드럽게 잘된다.. 움짤 변환과정에서 문제가 생긴거 같다.

키보드 밖 화면을 터치시 닫기

키보드 밖 화면을 터치하면 키보드가 닫히도록 구현을 해보겠다. 코드는 아래와 같다.

import SwiftUI
import KeyboardToolbar


extension UIApplication { // 키보드밖 화면 터치시 키보드 사라짐
    func endEditing() {
        sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
    }
}

struct CreateView: View {
    @State var title: String = ""
    @State var password: String = ""
    @State var verboseLeft: String = ""
    @State var verboseRight: String = ""
    @State var minuties: String = ""
    
    let buttons = ["섬멸전", "폭탄전", "스파이전"]
    @State public var buttonSelected: Int?
    @State private var showingAlert = false
    
    var body: some View {
        GeometryReader { gp in
            VStack(alignment:.center, spacing: 5) {
                
                TextField("방 제목", text: self.$title)
                    .padding()
                    .frame(width: gp.size.width * 0.9)
                
                Divider()
                    .frame(width: gp.size.width * 0.9)
                
                SecureField("비밀번호", text: self.$password)
                    .padding()
                    .frame(width: gp.size.width * 0.9)
                    .keyboardType(.numberPad)
                
                Divider()
                    .frame(width: gp.size.width * 0.9)
                
                HStack() {
                    TextField("5", text: self.$verboseLeft)
                        .keyboardType(.numberPad)
                        .padding()
                        .frame(width: gp.size.width * 0.2, alignment: .center)
                        .multilineTextAlignment(.center)
                    Text("VS")
                        .padding()
                    TextField("5", text: self.$verboseRight)
                        .keyboardType(.numberPad)
                        .padding()
                        .frame(width: gp.size.width * 0.2, alignment: .center)
                        .multilineTextAlignment(.center)
                }
                
                Divider()
                    .frame(width: gp.size.width * 0.9)
                
                HStack(){
                    TextField("게임시간(분)", text: self.$minuties)
                        .keyboardType(.numberPad)
                        .padding()
                        .multilineTextAlignment(.trailing)
                        .frame(width: gp.size.width * 0.7)
                    
                    Text("분")
                        .padding()
                        .frame(width: gp.size.width * 0.2)
                }
                
                Divider()
                    .frame(width: gp.size.width * 0.9)
                
                Group(){
                    HStack(){
                        Spacer()
                        Text("게임모드")
                            .padding()
                        Spacer()
                    }
                    HStack() {
                        ForEach(0..<buttons.count) { button in
                            Button(action: {
                                self.buttonSelected = button
                            }) {
                                VStack(){
                                    Image(self.buttonSelected == button ? self.buttons[button]+"-selected" : self.buttons[button])
                                        .resizable()
                                        .aspectRatio(contentMode: .fit)
                                        .frame(width: 80)
                                    Text("\(self.buttons[button])")
                                        .foregroundColor(self.buttonSelected == button ? Color.blue : Color.gray)
                                }
                                .frame(width: gp.size.width * 0.25, height: gp.size.width * 0.35)
                                .clipShape(Rectangle())
                                .border(self.buttonSelected == button ? Color.blue : Color.gray, width: 1)
                            }
                        }
                    }
                    
                }
                
                
                Button(action: {
                    print("Button action")
                    if self.create_validation() == false {
                        self.showingAlert = true
                    }
                }) {
                    HStack {
                        Text("생성하기")
                            .font(.title2)
                            .foregroundColor(Color.white)
                    }
                    .frame(width: gp.size.width * 0.6, height: gp.size.height * 0.1)
                    .background(Color.blue)
                    .cornerRadius(10)
                }
                .padding(.top, 30)
                .alert(isPresented: $showingAlert) {
                    Alert(title: Text("입력"), message: Text("모든 항목을 입력해주세요."), dismissButton: .default(Text("확인")))
                }
                
            }.listRowInsets(EdgeInsets())
            
        }
        .onTapGesture(count: 1) { // 키보드밖 화면 터치시 키보드 사라짐
            UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
        }
    }
    
    func create_validation() -> Bool {
        if self.title == "" {
            return false
        }
        else if self.password == "" {
            return false
        }
        else if self.verboseLeft == "" {
            return false
        }
        else if self.verboseRight == "" {
            return false
        }
        else if self.minuties == "" {
            return false
        }
        else if self.buttonSelected == nil {
            return false
        }
        
        return true
    }
}


위와 같이 구현하면 키보드 밖을 터치하면 키보드가 닫히게 된다.

extension UIApplication { // 키보드밖 화면 터치시 키보드 사라짐
    func endEditing() {
        sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
    }
}

간단하게 설명하자면 위 코드를 추가 하고,

.onTapGesture(count: 1) { // 키보드밖 화면 터치시 키보드 사라짐
            UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)

body 부분의 제일 밖에 이 코드를 추가하면 적용이 된다. 그렇게 하여 구현된 모습은 아래와 같다.

58E2D3DD-B724-49ED-A197-ED6802D7C17B

가상 디바이스가 아닌 실제 디바이스로 촬영해서 움짤로 변환한 것이라 어디를 터치했는지는 보이지 않는다. 그러나 터치를 이용하여 키보드 닫기가 구현된 것을 볼 수 있다.

키보드 밖에서 스크롤시 닫히게 하기

키보드 밖에서 화면 스크롤 제스처를 취하면 키보드가 닫히도록 구현하였다. 코드는 아래와 같다.

import SwiftUI
import KeyboardToolbar

extension View { // 키보드 밖 화면에서 스크롤시 키보드 사라짐
    func endEditing(_ force: Bool) {
        UIApplication.shared.windows.forEach { $0.endEditing(force)}
    }
}

struct CreateView: View {
    @State var title: String = ""
    @State var password: String = ""
    @State var verboseLeft: String = ""
    @State var verboseRight: String = ""
    @State var minuties: String = ""
    
    let buttons = ["섬멸전", "폭탄전", "스파이전"]
    @State public var buttonSelected: Int?
    @State private var showingAlert = false
    
    var body: some View {
        GeometryReader { gp in
            VStack(alignment:.center, spacing: 5) {
                
                TextField("방 제목", text: self.$title)
                    .padding()
                    .frame(width: gp.size.width * 0.9)
                
                Divider()
                    .frame(width: gp.size.width * 0.9)
                
                SecureField("비밀번호", text: self.$password)
                    .padding()
                    .frame(width: gp.size.width * 0.9)
                    .keyboardType(.numberPad)
                
                Divider()
                    .frame(width: gp.size.width * 0.9)
                
                HStack() {
                    TextField("5", text: self.$verboseLeft)
                        .keyboardType(.numberPad)
                        .padding()
                        .frame(width: gp.size.width * 0.2, alignment: .center)
                        .multilineTextAlignment(.center)
                    Text("VS")
                        .padding()
                    TextField("5", text: self.$verboseRight)
                        .keyboardType(.numberPad)
                        .padding()
                        .frame(width: gp.size.width * 0.2, alignment: .center)
                        .multilineTextAlignment(.center)
                }
                
                Divider()
                    .frame(width: gp.size.width * 0.9)
                
                HStack(){
                    TextField("게임시간(분)", text: self.$minuties)
                        .keyboardType(.numberPad)
                        .padding()
                        .multilineTextAlignment(.trailing)
                        .frame(width: gp.size.width * 0.7)
                    
                    Text("분")
                        .padding()
                        .frame(width: gp.size.width * 0.2)
                }
                
                Divider()
                    .frame(width: gp.size.width * 0.9)
                
                Group(){
                    HStack(){
                        Spacer()
                        Text("게임모드")
                            .padding()
                        Spacer()
                    }
                    HStack() {
                        ForEach(0..<buttons.count) { button in
                            Button(action: {
                                self.buttonSelected = button
                            }) {
                                VStack(){
                                    Image(self.buttonSelected == button ? self.buttons[button]+"-selected" : self.buttons[button])
                                        .resizable()
                                        .aspectRatio(contentMode: .fit)
                                        .frame(width: 80)
                                    Text("\(self.buttons[button])")
                                        .foregroundColor(self.buttonSelected == button ? Color.blue : Color.gray)
                                }
                                .frame(width: gp.size.width * 0.25, height: gp.size.width * 0.35)
                                .clipShape(Rectangle())
                                .border(self.buttonSelected == button ? Color.blue : Color.gray, width: 1)
                            }
                        }
                    }
                    
                }
                
                
                Button(action: {
                    print("Button action")
                    if self.create_validation() == false {
                        self.showingAlert = true
                    }
                }) {
                    HStack {
                        Text("생성하기")
                            .font(.title2)
                            .foregroundColor(Color.white)
                    }
                    .frame(width: gp.size.width * 0.6, height: gp.size.height * 0.1)
                    .background(Color.blue)
                    .cornerRadius(10)
                }
                .padding(.top, 30)
                .alert(isPresented: $showingAlert) {
                    Alert(title: Text("입력"), message: Text("모든 항목을 입력해주세요."), dismissButton: .default(Text("확인")))
                }
                
            }.listRowInsets(EdgeInsets())
            
        }
        .gesture(DragGesture().onChanged{_ in UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)}) // 키보드 밖 화면에서 스크롤시 키보드 사라짐

    }
    
    func create_validation() -> Bool {
        if self.title == "" {
            return false
        }
        else if self.password == "" {
            return false
        }
        else if self.verboseLeft == "" {
            return false
        }
        else if self.verboseRight == "" {
            return false
        }
        else if self.minuties == "" {
            return false
        }
        else if self.buttonSelected == nil {
            return false
        }
        
        return true
    }
}


위와 같이 구현하면 키보드를 띄운 후, 키보드 밖 화면을 어디든 드래그만 하면 키보드가 닫히는 것을 볼 수 있다.

혹시나 위 코드가 복잡하여 간단하게 뭐만 추가할지 보고 싶다면 아래 코드들을 추가하면 된다.

extension View { // 키보드 밖 화면에서 스크롤시 키보드 사라짐
    func endEditing(_ force: Bool) {
        UIApplication.shared.windows.forEach { $0.endEditing(force)}
    }
}
.gesture(DragGesture().onChanged{_ in UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)}) // 키보드 밖 화면에서 스크롤시 키보드 사라짐

결과 화면은 아래와 같다.

E5759340-A5D3-4C08-94A2-27EFC4019FF2

위 사진도 아까전과 마찬가지로 터치한 부분은 보이지 않지만 실제로 내가 드래그 제스쳐를 취했다..

결론

이렇게 총 3가지 방법을 이용하여 키보드 닫기를 구현해보았다. 만약 내가 개발하는 앱이 단순한 입력만 받는 것이라면 위에 소개한 3가지 방법중 어떤 것이든 사용해도 상관이 없을 듯 하다. 그러나 좀 세부적인 입력을 받는 부분이 조금이라도 있다면 제일 첫번째 소개한 키보드 툴바 라이브러리를 이용하여 구현하는 것이 좋겠다.

다음에는 키보드의 return 또는 done 버튼을 터치시 다음 입력창으로 넘어가는 것을 구현해보도록 하겠다.