본문 바로가기

학습 노트/iOS (2021)

053 ~ 061. Software Keyboard, Text Delegate, Input View & Input Accessory View and Password Auto Fill

Software Keyboard


캘린더에서 Location을 선택하면 오른쪽의 화면으로 전환됨과 동시에,
키보드를 조작하면 검색 필드에 입력할 수 있게끔 동작한다.
다른 뷰로 포커스를 전환하거나 종료하기 전까지 이벤트를 처리하게 되는데 이를 First Responder라고 부른다.

//
//  FirstResponderViewController.swift
//  HandlingText
//
//  Created by Martin.Q on 2021/08/24.
//

import UIKit

class FirstResponderViewController: UIViewController {
	@IBOutlet weak var textField: UITextField!
	@IBAction func startAction(_ sender: Any) {
	}
	@IBAction func endAction(_ sender: Any) {
	}


override func viewDidLoad() {
	super.viewDidLoad()
	}

}

사용할 씬과 연결된 코드는 위와 같다.

@IBAction func startAction(_ sender: Any) {
	textField.becomeFirstResponder()
}
@IBAction func endAction(_ sender: Any) {
	if textField.isFirstResponder {
    	textField.resignFirstResponder()
    }
}

각각의 버튼은 becomeFirstResponder와 resignFirstResponder 메서드를 사용해 FirstResponder 속성을 변경한다.


결과


기본적으로 TextView나 TextField를 탭 하면, FirstResponder가 되고,
다른 뷰로 로커스를 이동하거나 다른 화면으로 전환되면 해당 상태가 해제 된다.
viewWillAppear나 viewDidLoad에서 becomeFirstResponder 메서드를 사용하면
화면이 전환된 직후 텍스트를 입력받을 수 있는 상태로 만들 수도 있다.

왼쪽은 viewWillAppear와 viewWillDisappear에 적용했을 경우,
오른쪽은 viewDidLoad와 viewDidDisappear에 적용했을 경우이다.
전자는 크게 차이가 나지 않지만, 다시 돌아가는 경우 차이가 생긴다.
이 처럼 뷰가 사라지는 경우까지 대응하지 않아도 동작하기는 하지만,
부자연스러운 경우가 생기거나, 반응이 늦는 경우가 생겨 습관적으로 해 주는 것이 좋다.

override func viewWillDisappear(_ animated: Bool) {
	super.viewWillDisappear(animated)
	view.endEditing(true)
}

만약 textField나 textView가 많다면 if문으로 일일이 확인하는 대신,
위와 같이 편집 모드를 종료하는 것으로 똑같은 효과를 기대할 수 있다.

 

Keyboard Type

iOS는 여러 타입의 키보드를 제공한다.

//
//  TypeViewController.swift
//  HandlingText
//
//  Created by Martin.Q on 2021/08/24.
//

import UIKit

class TypeViewController: UIViewController {
	@IBOutlet weak var textField: UITextField!
	@IBAction func changeAction(_ sender: Any) {
	
	}
	
	
	
	override func viewDidLoad() {
		super.viewDidLoad()
	}
}

사용할 씬과 연결된 코드는 위와 같다.

키보드 타입은 textField의 Keyboard Type 속성에 따라 자동으로 결정된다.
기본값인 Default로 사용해도 문제는 없지만, 적합한 키보드를 선택해 주면 사용성 개선을 기대할 수 있다.

@IBAction func changeAction(_ sender: Any) {
	textField.resignFirstResponder()
	
	let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
	
}

버튼을 누르면 actionSheet 방식으로 여러 옵션들 중 하나를 고를 수 있도록 구현한다.

public enum UIKeyboardType : Int {
	
	
	case `default` = 0 // Default type for the current input method.
	
	case asciiCapable = 1 // Displays a keyboard which can enter ASCII characters
	
	case numbersAndPunctuation = 2 // Numbers and assorted punctuation.
	
	case URL = 3 // A type optimized for URL entry (shows . / .com prominently).
	
	case numberPad = 4 // A number pad with locale-appropriate digits (0-9, ۰-۹, ०-९, etc.). Suitable for PIN entry.
	
	case phonePad = 5 // A phone pad (1-9, *, 0, #, with letters under the numbers).
	
	case namePhonePad = 6 // A type optimized for entering a person's name or phone number.
	
	case emailAddress = 7 // A type optimized for multiple email address entry (shows space @ . prominently).
	
	@available(iOS 4.1, *)
	case decimalPad = 8 // A number pad with a decimal point.
	
	@available(iOS 5.0, *)
	case twitter = 9 // A type optimized for twitter text entry (easy access to @ #)

	@available(iOS 7.0, *)
	case webSearch = 10 // A default keyboard type with URL-oriented addition (shows space . prominently).
	
	@available(iOS 10.0, *)
	case asciiCapableNumberPad = 11 // A number pad (0-9) that will always be ASCII digits.
	
	
	public static var alphabet: UIKeyboardType { get } // Deprecated
}

keyboardType에 해당하는 속성은 keyboardType이고, UIKeyboardType의 형식으로 되어있다.
UIKeyboardType은 위처럼 정수의 원시 값을 가지는 열거형으로 되어 있고,

let option: [UIKeyboardType] = [.default, .asciiCapable, .numbersAndPunctuation, .URL, .numberPad, .phonePad, .namePhonePad, .emailAddress, .decimalPad, .twitter, .webSearch, .asciiCapableNumberPad]
let name = ["Default", "ASCIII", "Number and Punctuation", "URL", "Number Pad", "Phone pad", "Name Phone Pad", "Email", "Decimal", "Twitter", "web search", "ASCII capable number pad"]

따라서 열거형의 옵션들을 option  배열에 순서대로 저장했다.
name 배열을 actionSheet에 버튼으로 표시될 이름을 저장했다.

(0..<name.count).forEach {
	let option = option[$0]
	let name = name[$0]
	
	let action = UIAlertAction(title: name, style: .default) { (action) in
	self.textField.keyboardType = option
	self.textField.becomeFirstResponder()
}

forEach를 사용해 순환하여 추가하게끔 구성했다.
선택된 옵션으로 keyboardType을 설정하고, 다시 textField를 firstResponder로 지정해 편집 모드로 진입한다.

@IBAction func changeAction(_ sender: Any) {
	textField.resignFirstResponder()
	
	let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
	
	let option: [UIKeyboardType] = [.default, .asciiCapable, .numbersAndPunctuation, .URL, .numberPad, .phonePad, .namePhonePad, .emailAddress, .decimalPad, .twitter, .webSearch, .asciiCapableNumberPad]
	let name = ["Default", "ASCIII", "Number and Punctuation", "URL", "Number Pad", "Phone pad", "Name Phone Pad", "Email", "Decimal", "Twitter", "web search", "ASCII capable number pad"]
	
	(0..<name.count).forEach {
		let option = option[$0]
		let name = name[$0]
		
		let action = UIAlertAction(title: name, style: .default) { (action) in
			self.textField.keyboardType = option
			self.textField.becomeFirstResponder()
		}
		sheet.addAction(action)
	}
	
	let cancel = UIAlertAction(title: "Cancel", style: .destructive, handler: nil)
	sheet.addAction(cancel)
	
	present(sheet, animated: true, completion: nil)
}

이후 actionSheet를 탈출할 수 있는 canel버튼을 추가 한 뒤, 화면에 표시할 수 있도록 present메서드를 작성한다.


결과

옵션에 따라 바뀌는 키보드는 왼쪽에서 오른쪽 순서대로 위와 같다.
각각의 키보드는 type에 따라 조금씩 다른 레이아웃을 보여 주거나, 입력 문자를 제한하고, 입력을 용이하게 하는 레이아웃을 가지게 된다.


Keyboard Appearance

//
//  AppearanceViewController.swift
//  HandlingText
//
//  Created by Martin.Q on 2021/08/24.
//

import UIKit

class AppearanceViewController: UIViewController {
    @IBOutlet weak var textField: UITextField!
    @IBAction func segmentAction(_ sender: UISegmentedControl) {
    }
    

    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

사용할 씬과 연결된 코드는 위와 같다.

//
//  AppearanceViewController.swift
//  HandlingText
//
//  Created by Martin.Q on 2021/08/24.
//

import UIKit

class AppearanceViewController: UIViewController {
	@IBOutlet weak var textField: UITextField!
	@IBAction func segmentAction(_ sender: UISegmentedControl) {
		textField.keyboardAppearance = UIKeyboardAppearance(rawValue: sender.selectedSegmentIndex) ?? .default
	}
	
	override func viewDidLoad() {
		super.viewDidLoad()
		textField.becomeFirstResponder()
	}
}

Keyboard Appearance는 UIKeyboardAppearance 열거형으로 선언되어있다.
따라서 UIKeyboardAppearance로 segment의 인덱스를 받아 설정하고, 이를 전달해 구현한다.


결과

  • Default
    기기 설정에 따라 Dark 모드와 Light 모드를 표시한다.
  • Dark
    기기 설정에 관계없이 Dark 모드로 표시한다.
  • Light
    기기 설정에 관계없이 Light 모드로 표시한다.
  • Alert
    일반적으론 Dark와 같은 결과지만 앱 테마와 굳이 통일하려는 경우를 제외하곤 사용하지 않는다.

Retrun Key

Return 키를 커스터마이징 하거나, Return 키를 통해 여러 동작을 처리할 수 있다.

Text Input Traits의 Return Key 옵션 아래의 Auto-enable Return Key는 다음과 같은 기능을 한다.

입력창이 비어있으면 Return 키가 비활성화되고,
입력값이 생기게 되면 그제야 활성화된다.

//
//  ReturnViewController.swift
//  HandlingText
//
//  Created by Martin.Q on 2021/08/24.
//

import UIKit

class ReturnViewController: UIViewController {
	@IBOutlet weak var upperTextField: UITextField!
	@IBOutlet weak var lowerTextField: UITextField!
	
	
	override func viewDidLoad() {
		super.viewDidLoad()
	
	
	}
}

사용할 씬과 연결된 코드는 위와 같다.

override func viewDidLoad() {
	super.viewDidLoad()
	lowerTextField.enablesReturnKeyAutomatically = true
	
	
}

코드로는 위와 같이 enablesReturnKeyAutomatically 속성을 true로 바꿔주면 된다.

또한 ReturnKey 자체를 바꿀 수 있는데 위와 같은 선택이 가능하다.


결과

각각의 옵션은 위와 같은 모습으로 ReturnKey를 표시한다.


ReturnKey에 기능 연결하기

첫 번째 TextField에서 ReturnKey를 누르면 두 번째 TextField로 이동하고,
두 번째 TextField에서 ReturnKey를 누르면 검색을 실행하도록 구현한다.
이러한 기능은 Delegate pattern으로 구현한다.

따라서 TextField의 ConnectionPannel에서 Delegate를 해당 씬의 Controller에 연결한다.

extension ReturnViewController: UITextFieldDelegate {
    
}

코드에는 extension으로 UITextFieldDelegate를 구현한다.

extension ReturnViewController: UITextFieldDelegate {
	func textFieldShouldReturn(_ textField: UITextField) -> Bool {
		return true
	}
}

델리게이트 내부의 textFieldShouldReturn 메서드는 TextField에서 Return키를 누를 때마다 반복적으로 호출된다.
또한, Bool을 반환하되, 특별한 이유가 없다면 True를 기본 값으로 사용하도록 한다.

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
	switch textField {
	case upperTextField:
		lowerTextField.becomeFirstResponder()
	default:
    	break
return true
}

textField에 대하여 Switch-case 문을 구성한다.
첫 번째 TextField에서 ReturnKey가 입력되는 경우 다음 TextField로 포커스를 전환하고,

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
	switch textField {
	case upperTextField:
		lowerTextField.becomeFirstResponder()
	case lowerTextField:
		guard let input = lowerTextField.text, input.count > 0 else {
			return true
		}
		let encode = input.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? input
	default:
		break
	}

return true
}

두 번째 TextField에서 ReturnKey가 입력되는 경우 입력된 키워드를 검증 후에 한국어 검색이 가능하도록
percentEncoding을 진행한다.
iOS의 URL String은 영문, 숫자, 약간의 특수문자만 인식하기 때문에
한글 등의 별도의 문자를 인식하기 위해 urlQuery 방식의 문자열을 추가해 줘야 한다.

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
	switch textField {
	case upperTextField:
		lowerTextField.becomeFirstResponder()
	case lowerTextField:
		guard let input = lowerTextField.text, input.count > 0 else {
			return true
		}
		let encode = input.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? input
		let url = "https://google.com/search?q=\(encode)"
		
		guard let url = URL(string: url) else {
			return true
		}
	default:
		break
	}

return true
}

이후 검색 URL을 생성하고, 해당 URL을 검증한 뒤에

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
	switch textField {
	case upperTextField:
		lowerTextField.becomeFirstResponder()
	case lowerTextField:
		guard let input = lowerTextField.text, input.count > 0 else {
			return true
		}
		let encode = input.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? input
		let url = "https://google.com/search?q=\(encode)"
		
		guard let url = URL(string: url) else {
			return true
		}
		
		if UIApplication.shared.canOpenURL(url) {
			UIApplication.shared.open(url, options: [:], completionHandler: nil)
		}
	default:
		break
	}

return true
}

Safari로 전달해 검색을 실행한다.


결과


Keyboard Notification

TextView의 가장 아랫부분이 Keyboard에 가려져서 정상적인 사용이 불가능하다.
해당 문제를 해결하기 위해선 KeyboardNotification을 활용해
키보드가 차지한 영역을 인식하고 UI를 업데이트할 수 있도록 수정해야 한다.

keyboard와 연관된 notification은 UIResponder 클래스에 위와 같이 선언되어있다.
각각 키보드가 표시되기 전후, 키보드가 사라지기 전후, 키보드의 프레임이 변하기 전후에 notification을 발생한다.

//
//  KeyboardNotificationViewController.swift
//  HandlingText
//
//  Created by Martin.Q on 2021/08/25.
//

import UIKit

class KeyboardNotificationViewController: UIViewController {
	@IBOutlet weak var textView: UITextView!

	override func viewDidLoad() {
		super.viewDidLoad()
	}

}

사용할 코드는 위와 같다.

override func viewDidLoad() {
	super.viewDidLoad()
	
	NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { (noti) in
	
	}
}

notification은 특정 대상에게 직접 전달할 수 없고, 전체의 대상에 발생했음을 알린다.
따라서 항시 대기하고 있다가 이를 감지할 수 있는 대상이 있어야 하는데 이것이 Observer이다.
Observer는 특정한 notification에 반응하도록 작성되는데 이번엔 keyboardWillShowNotification이고,
키보드가 표시되기 직전에 발생되는 notification이다.

override func viewDidLoad() {
	super.viewDidLoad()
	
	NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { (noti) in
	
		var inset = self.textView.contentInset
		inset.bottom = 300
		self.textView.contentInset = inset
	}
}

해당 notification을 감지하면 전달되는 클로저를 실행하게 된다.
우선 textField의 inset을 저장한 뒤 inset의 bottom값을 바꾸어 이를 다시 textField에 저장한다.

override func viewDidLoad() {
	super.viewDidLoad()
	
	NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { (noti) in
	
		var inset = self.textView.contentInset
		inset.bottom = 300
		self.textView.contentInset = inset
	}
    NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { (noti) in

		var inset = self.textView.contentInset
		inset.bottom = 20
		self.textView.contentInset = inset
	}
}

키보드가 사라졌을 때는 원래의 여백인 20으로 되돌려 저장한다.


결과

일단은 키보드가 textView를 가리는 문제는 해결되었다.
하지만, 키보드의 높이는 키보드의 레이아웃에 따라 유동적이며,
현재 뷰의 위치를 나타내는 스크롤바는 아직 키보드에 가려져 정상적으로 사용할 수 없다.
따라서 지금처럼 고정된 값(300)으로 inset을 변경하는 것은 바람직하지 않다.

NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { (noti) in
	guard let info = noti.userInfo else {
		return
	}
	guard let frame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
		return
	}
}

observer에서 전달되는 파라미터인 noti에는 유저 화면에 관한 정보가 함께 담겨있다.

키보드 정보에 접근하기 위해 UIResponder 클래스의 keyboardFrameEndUserIfoKey에서 최종 사용자의 키보드 frame 정보를 꺼내온다.

@available(iOS 3.2, *)
public class let keyboardFrameEndUserInfoKey: String // NSValue of CGRect

해당 클래스는 NSValue 형식으로 되어있으므로, 상세적인 정보에 접근하려면 NSValue의 형식으로 데이터를 꺼내와야 하기 때문이다.

NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { (noti) in
	guard let info = noti.userInfo else {
		return
	}
	guard let frame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
		return
	}
	
	var inset = self.textView.contentInset
	inset.bottom = frame.height
	self.textView.contentInset = inset
	self.textView.scrollIndicatorInsets = inset
}

이후 이렇게 가져온 키보드의 frame 사이즈에서 높이의 기준으로 textView와 ScrollIndicator의 inset도 변경한다.

override func viewDidLoad() {
	super.viewDidLoad()

	NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { (noti) in
		guard let info = noti.userInfo else {
			return
		}
		guard let frame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
			return
		}
	
		var inset = self.textView.contentInset
		inset.bottom = frame.height
		self.textView.contentInset = inset
		self.textView.scrollIndicatorInsets = inset
	}
	NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { (noti) in
		
		var inset = self.textView.contentInset
		inset.bottom = 20
		self.textView.contentInset = inset
		self.textView.scrollIndicatorInsets = inset
	}
}

키보드가 사라질 때에는 추가된 scrollIndicator도 함께 초기화해 줘야 한다.


결과

이젠 키보드의 사이즈에 맞게 inset이 변경된다.
사진에서 최하단에 여백이 존재하는 이유는 textField의 frame의 bottom이 스크린 전체를 채우지 않고,
safeArea의 20만큼 간격이 있기 때문으로, 이를 수정하면 해당 여백은 사라진다.


 

Text Delegate


TextField UITextFieldDelegate
TextView UITextViewDelegate

textField와 textView는 각각의 delegate 프로토콜로 이벤트를 처리한다.
이 둘은 이름은 다르지만 서로 비슷한 점이 상당히 많다.

TextField Tap Become First Responder Start Editing Session
TextView

이 둘은 영역을 터치하면 FirstResonder로 지정되고, 편집 모드가 시작된다.

    ⬅️false                             true➡️    
TextField Tap textFieldShouldBeginEditing() Become First Responder Start Editing Session
TextView textViewShouldBeginEditing()
     ⬅️false                            true➡️    

text-ShouldBeginEditing 메서드가 true를 반환하면 FirstResponder가 되고,
이는 Delegate에서 따로 구현하지 않으면 자동으로 true를 반환하게 된다.
하지만 flase를 반환하게 되면 firstResponder가 되지 않는다.

     
TextField Start Editing Session textFieldDidBeginEditing()
TextView textViewDidBeginEditing()
     

start Editing session 상태가 되면 text-DidBeginEditing 메서드를 호출한다.

TextField textField(_:shouldChangeCharactersIn:replacementString:)
textFieldShouldClear(_:)
textFieldShouldReturn(_:)
TextView textView(_:shouldChangeTextIn:replacementText:)
textViewDidchange(_:)
textViewDidChangeSelection(_:)
textView(_:shouldInteractionWith:in:interaction:)

textField는 문자를 입력하거나 삭제하면
textField(_:shouldChangeCharactersIn:replacementString:)메서드를 반복적으로 호출한다.
호출하면서 추가되거나 삭제되는 문자열의 범위를 shouldChangeCharactersIn파라미터로 전달하고, 
대체될 문자열을 replacementString파라미터로 전달한다. 즉, 입력이라면 새로운 문자열을, 삭제라면 빈 문자열을 전달한다.
이후 문자열의 크기를 확인해서 입력과 삭제를 구별할 수 있게 된다.
반환형은 Boolean으로 true라면 편집한 내용을 반영한다. false라면 현재 값을 유지한다.
문자의 수를 제한하거나 특정 문자의 입력을 제한하기 위해 해당 메소드를 사용한다.

textField의 오른쪽에 생기는 'X'표를 누르면 textFieldShouldClear 메서드가 호출된다.
이는 true를 반환하게 되면 textField를 공란으로 초기화한다.

textField에서 return 키를 입력하면 textFieldShoulfReturn 메서드를 호출한다.

textView는 문자를 입력하거나 삭제할 때마다 textView(_:shouldChangeTextIn:replacementText:) 메소드를 반복적으로 호출한다.
반환형과 작동 원리는 textField의 것과 동일하다.
단, true가 반환되게 되면 곧바로 textViewDidChange메서드를 호출한다.

textView에서 범위를 선택하거나 입력 커서를 이동시키면 textViewDidChangeSelection 메소드를 호출한다.

textView는 attachment 객체를 사용해 이미지를 출력할 수 있고, dataDetection을 사용해 전화번호 등을 인식할 수 있다.
이러한 것들을 터치했을 때는 textView(_:shouldInteractionWith:in:interaction:) 메소드를 호출한다.
true를 반환하면 액션을 실행하고, false라면 무시한다.

  ⬅️false                             true➡️  
TextField textFieldShouldEndEditing(_:) Resign First Responder
TextView textViewShouldEndEditing(_:)
  ⬅️false                             true➡️  

현재 입력 중인 화면을 벗어나거나 다른 곳으로 focus를 이동하게 되면 text-ShouldEndEditing 메소드를 호출한다.
true를 반환하면 해당 뷰의 FirstResponder 속성을 해제한다.

TextField Resign First Responder End Editing Session textFieldDidEndEditing(_:)
TextView textViewDidEndEditing(_:)

FirstResponder가 해제되고 편집 모드가 종료되면 text-DidEndEditing메서드를 호출한다.

실습

Delegate 패턴에서 첫 번째로 전달되는 파라미터는 Delegate 메소드를 호출한 뷰이다.
화면엔 여러 개의 textView나 textField가 존재할 수 있고, 이들은 하나의 Delegate를 공유하는 경우가 많다.
따라서 전달되는 파라미터를 통해 어떤 뷰인지 확인하고, 코드를 분기하는 방식을 주로 사용한다.

//
//  TextDelegateViewController.swift
//  HandlingText
//
//  Created by Martin.Q on 2021/08/25.
//

import UIKit

class TextDelegateViewController: UIViewController {
    @IBOutlet weak var textField1: UITextField!
    @IBOutlet weak var textField2: UITextField!
    @IBOutlet weak var textField3: UITextField!
    @IBOutlet weak var textField4: UITextField!
    
    let regex = "^([a-z0-9_\\.-]+)@([\\da-z\\.-]+)\\.([a-z\\.]{2,6})$"
    

    override func viewDidLoad() {
        super.viewDidLoad()
    }

}

사용할 씬과 코드는 위와 같고, TextField의 Delegate는 해당 씬의 Controller에 연결했다.
regex는 textField에 입력되는 값들을 검증하기 위한 자연어 처리 변수이다.

override func viewDidLoad() {
	super.viewDidLoad()
	textField1.returnKeyType = .next
	textField1.enablesReturnKeyAutomatically = true
	textField2.returnKeyType = .next
	textField2.enablesReturnKeyAutomatically = true
	textField3.returnKeyType = .next
	textField3.enablesReturnKeyAutomatically = true
	textField4.returnKeyType = .done
	textField4.enablesReturnKeyAutomatically = true
}

textField들은 위와 같이 return 타입과 auto enabled return key를 설정해 준다.

override func viewWillAppear(_ animated: Bool) {
	super.viewWillAppear(animated)
	textField1.becomeFirstResponder()
}

또한, 화면에 진입하자마자 첫 번째 필드에 입력할 수 있도록 ViewWillAppear에서 FirstResponder로 지정한다.

extension TextDelegateViewController: UITextFieldDelegate {
    
}

이후 클래스의 밖에서 extension으로 UITextFieldDelegate를 구현한다.

extension TextDelegateViewController: UITextFieldDelegate {
	func textFieldShouldReturn(_ textField: UITextField) -> Bool {
		switch textField {
		case textField1:
			textField2.becomeFirstResponder()
		case textField2:
			textField3.becomeFirstResponder()
		case textField3:
			textField4.becomeFirstResponder()
		case textField4:
			textField4.resignFirstResponder()
		default:
			break
		}
	return true
	}
}

return을 입력하면 textFieldShouldReturn 메소드를 호출하게 된다.
textField에 따라 다음 필드를 FirstResponder로 설정하고, 마지막 필드에선 편집 모드를 종료한다.
이어 해당 메서드는 최종적으로 true를 전달한다.


결과


func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
	
	print("current: \(textField.text ?? "")")
	print("string: \(string)")
	
	return true
	
}

textField는 값이 변할 때마다 위의 메서드를 호출한다.


결과

시뮬레이터를 보면 current가 string보다 늦게 반응하는 것이 확인되는데,
이는 키보드에서 입력을 한 직후 메서드가 동작하고,
메서드에서 true를 반환한 이후에야 필드에 반영되기 때문이다.
또한 입력한 값은 세 번째 파라미터인 string을 통해 전달되는 것을 확인할 수 있다.

삭제하는 경우 세 번째 파라미터는 빈 문자열이다.
따라서 세번째 파라미터의 count 속성으로 0이면 삭제, 1이면 입력으로 구분할 수 있다.


입력 제한하기

문자 수 제한하기

따라서 현재 입력되고 있는 문자열을 알아내려면 입력되어있는 문자열과 범위와 입력될 문자열을 조합해야 한다.

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
	
	let current = NSString(string: textField.text ?? "")
	let after = current.replacingCharacters(in: range, with: string)
	
	switch textField {
	case textField1:
		if after.count > 10 {
			return false
		}
	default:
		break
	}
	
return true

}

이때 이미 range 파라미터가 사용하고 있는 NSRange를 참고해 NSString으로 변환해 사용하면,
NSRange에서 Range로 변환해서 전달해야 하는 번거로움이 사라져 코드가 단순해진다.


결과

이제 textField1에서는 10글자 이상 입력되지 않는다.


 

특정 범위의 정수로 제한하기

textField2.returnKeyType = .next
textField2.enablesReturnKeyAutomatically = true
textField2.keyboardType = .numberPad

키보드 레이아웃 설정을 numberPad 설정한다.
이렇게 되면 1차적으로 키보드에선 숫자만 입력할 수 있게 된다.

switch textField {
	case textField1:
		if after.count > 10 {
			return false
		}
	case textField2:
		if let text = Int(after), !(1...100).contains(text) {
			return false
		}
	default:
		break
}

현재 입력된 텍스트를 정수형으로 받아 1~100 사이의 값으로 검증한 뒤,
조건에 만족하지 않는다면 입력을 멈춘다.


결과

numberPad에는 returnKey가 존재하지 않는다.
또한 현재의 필드에는 외부 키보드로 숫자 이외의 값이 입력 가능하며, 복사, 붙여 넣기도 가능하다.


case textField2:
//            if let text = Int(after), !(1...100).contains(text) {
//                return false
//            }
	let char = CharacterSet(charactersIn: "0123456789").inverted

값 검증을 위해 새로운 캐릭터 셋을 만든다.
숫자만 입력한 캐릭터 셋을 만든 뒤 inverted로 뒤집으면 숫자를 제외한 모든 문자를 포함한다.
하지만 그런 만큼 굉장히 무거운 인스턴스가 되기 때문에 입력할 때마다 반복적으로 생성하는 것은 비효율적이다.

lazy var char = CharacterSet(charactersIn: "0123456789").inverted

따라서 위의 형태로 클래스에서 선언하는 것으로 변경하고,

case textField2:
//            if let text = Int(after), !(1...100).contains(text) {
//                return false
//            }
	if let _ = string.rangeOfCharacter(from: char) {
		return false
	}

원래 존재하던 곳에서는 해당 캐릭터 셋과 비교해 검증해 입력을 금지한다.

case textField2:
	if let _ = string.rangeOfCharacter(from: char) {
		return false
	}
	if let text = Int(after), !(1...100).contains(text) {
		return false
	}

이후 원래 작성했었던 범위 검증 코드를 다시 살려주면 의도한 대로 동작하게 된다.


결과


textField3는 입력 가능한 문자를 대문자 M과 F로 제한하고, 입력 가능한 문자의 수도 하나로 제한한다.

textField3.returnKeyType = .next
textField3.enablesReturnKeyAutomatically = true
textField3.autocapitalizationType = .allCharacters

textField3의 autoCapitalizationType을 allCharacters로 설정해 주면 해당 textField에는 대문자만 입력 가능해진다.

lazy var charGender = CharacterSet(charactersIn: "MF").inverted

이전과 비슷한 방식으로 M과 F를 제외한 캐릭터 셋을 만들고,

case textField3:
if after.count > 1 {
	return false
}
if let _ = string.rangeOfCharacter(from: charGender) {
	return false
}

글자 수를 제한하고, 해당 캐릭터 셋과 비교 검증하도록 코드를 작성한다.


결과


Email 형식 제한하기

Email 검증은 편집 도중이 아닌 편집 완료 후 검증하도록 구현한다.

extension TextDelegateViewController {
	func alert(message: String) {
		let alert = UIAlertController(title: "Warning", message: message, preferredStyle: .alert)
		let okBtn = UIAlertAction(title: "OK", style: .default, handler: nil)
		alert.addAction(okBtn)
		
		present(alert, animated: true, completion: nil)
	}
}

우선 경고창을 표시할 수 있도록 alert 메서드를 하나 만든다.

func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
	if textField == textField4 {
	
	}
	return true
}

이후 textField의 편집이 종료됐을 때 실행될 수 있도록 textFieldShouldEndEditing 메소드를 구현하고,
textField가 textField4일 때만 실행되도록 조건을 추가한다.

func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
	if textField == textField4 {
		guard let email = textField.text, let _ = email.range(of: regex, options: .regularExpression) else {
			alert(message: "invalid email")
            return false
		}
	}
return true
}

이후엔 문자열에 정규식 패턴이 존재하는지 미리 만들어둔 regex와 비교한다.
존재하지 않는 다면 경고창을 표시하고, 입력을 금지한다.


결과

 

Input View & Input Accessory View


textField나 textView가 FirstResponder가 되면, 편집 모드로 전환되고, 키보드가 표시된다.
이때 키보드가 아닌 별도의 커스텀 뷰를 표시할 수 있도록 할 수 있는데, 이것을 Input View라고 부른다.

//
//  InputViewViewController.swift
//  HandlingText
//
//  Created by Martin.Q on 2021/08/26.
//

import UIKit

class InputViewViewController: UIViewController {
	@IBOutlet weak var textField1: UITextField!
	@IBOutlet weak var textField2: UITextField!
	@IBOutlet weak var textField3: UITextField!
	
	
	override func viewDidLoad() {
		super.viewDidLoad()
	}
}

사용할 씬과 연결된 코드는 위와 같다.
나이를 입력하는 필드는 키보드 대신 1~100 범위의 pickerView를,
성별을 입력하는 필드는 키보드 대신 두 개의 버튼을 표시하고 성별을 선택할 수 있도록 한다.

표시할 뷰는 코드로도 만들 수 있고, storyboard로도 반들 수 있다.
storyboard에선 위와 같이 씬이 아닌 씬 독에 View를 추가하고, sizeInspector에서 너비와 높이를 위와 같이 설정한다.

해당 뷰에 pickerView를 추가하고 모든 여백을 0으로 제약을 추가한다.
이후 delegate와 dataSource 씬에 연결한다.

class InputViewViewController: UIViewController {
	@IBOutlet weak var textField1: UITextField!
	@IBOutlet weak var textField2: UITextField!
	@IBOutlet weak var textField3: UITextField!
	@IBOutlet var pickerContainer: UIView!
	//...
}

이후 해당 뷰를 코드에 outlet으로 연결한다.

extension InputViewViewController: UIPickerViewDataSource {
	func numberOfComponents(in pickerView: UIPickerView) -> Int {
		return 1
	}

	func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
		return 100
	}
}
extension InputViewViewController: UIPickerViewDelegate {
	func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
		return "\(row + 1)"
	}
}

이후 코드에서 pickerView의 필수 메소드를 구현한다.
dataSource에서 컴포넌트와 항목의 수를 설정하고,
delegate에서 표시할 항목을 결정한다. 여기선 row의 인덱스에 1을 더한 값으로 1 ~ 100의 텍스트를 출력하므로,
pickerView(titleForRow:) 메소드를 사용했다.

extension InputViewViewController: UIPickerViewDelegate {
	func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
		return "\(row + 1)"
	}
	func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
		self.textField2.text = "\(row + 1)"
	}
}

선택된 항목의 값을 나이 필드에 입력하도록 했다.

override func viewDidLoad() {
	super.viewDidLoad()
	textField2.inputView = pickerContainer
}

이렇게 작성한 뷰를 textField의 inputView로 지정한다.
inputView에 뷰를 전달하면 해당 뷰를 키보드 대신 표시하고,
nil을 전달하면 키보드를 표시하게 된다.


결과


pickerView에서 선택한 항목이 Age 필드에 자동으로 입력되는 것이 확인됐다.

class InputViewViewController: UIViewController {
	@IBOutlet weak var textField1: UITextField!
	@IBOutlet weak var textField2: UITextField!
	@IBOutlet weak var textField3: UITextField!
	@IBOutlet var pickerContainer: UIView!
	@IBOutlet var genderContainer: UIView!
    
	@IBAction func genderBtn(_ sender: UIButton) {
	}
	
	
	
	override func viewDidLoad() {
		super.viewDidLoad()
		textField2.inputView = pickerContainer
	}
}

이번엔 Gender 필드에 사용할 inputView를 pickerView와 같이 생성하고,
outlet으로 코드에 연결하고, 안에 포함된 버튼은 하나의 Action으로 연결해 같은 동작을 수행하도록 한다.

@IBAction func genderBtn(_ sender: UIButton) {
	switch sender.tag {
	case 1:
		textField3.text = "M"
	case 2:
		textField3.text = "F"
	default:
		break
	}
}

이후 버튼의 tag설정을 바꿔 준 뒤, 전달되는 tag에 따라 분기하여 각각의 텍스트를 입력하도록 한다.

override func viewDidLoad() {
	super.viewDidLoad()
	textField2.inputView = ageContainer
	textField3.inputView = genderContainer
}

이후엔 동일하게 inputView로 설정해 준다.


결과


단, 버튼들은 키보드나 pickerView와 같이 피드백 사운드가 존재하지 않는다.

class GenderView: UIView, UIInputViewAudioFeedback {
	var enableInputClicksWhenVisible: Bool {
		return true
	}
}

이를 위해선 해당 뷰가 사용할 새로운 클래스를 생성해야 하고,
클래스는 UIView를 반드시 상속받아야 하며,
UIInputViewAudioFeedback 프로토콜을 채용해야 피드백 사운드를 사용할 수 있다.
채용한 피드백의 enableInputClickWhenVisible 속성을 사용해 소리를 출력하도록 설정한다.
이후 해당 클래스를 genderContainer의 커스텀 클래스로 설정한다.

@IBAction func genderBtn(_ sender: UIButton) {
	switch sender.tag {
	case 1:
		textField3.text = "M"
		UIDevice.current.playInputClick()
	case 2:
		textField3.text = "F"
		UIDevice.current.playInputClick()
	default:
		break
	}
}

입력 이후 UIDevice의 current 속성의 playInputClick 메소드를 호출하도록 하면,
키보드를 입력할 때와 같은 소리가 나게 된다.

기능적으로는 문제가 없지만 inputView를 사용한 경우 디자인 적으로 어색한 부분이 있다.
폭은 view의 너비에 따라 자동으로 대응하지만 높이의 경우 원래의 높이를 사용하게 된다.
따라서 키보드와 비슷한 높이를 맞춰 줄 필요가 있고, 화면과 구분되게 배경색 등을 변경해 줄 필요가 있다.

attribute inspector와 size inspector에서 값을 변경해 주고 다시 실행해 보면,


결과


Input Accessory

다음 버튼이나 이전 버튼이 없어서 사용자가 일일이 textField를 선택해야 포커스를 전환할 수 있다.
inputView에 버튼을 추가해 사용성을 높일 수 있는데, 이것이 input accessory view다.

이번에는 씬 독에 toolbar를 추가하고 버튼들을 디자인한다.

 @IBOutlet weak var textField1: UITextField!
 @IBOutlet weak var textField2: UITextField!
 @IBOutlet weak var textField3: UITextField!
 @IBOutlet var ageContainer: UIView!
 @IBOutlet var genderContainer: UIView!
 @IBOutlet var accBar: UIToolbar!

outlet으로 연결하고,

override func viewDidLoad() {
	super.viewDidLoad()
	textField1.inputAccessoryView = accBar
	textField2.inputView = ageContainer
	textField2.inputAccessoryView = accBar
	textField3.inputView = genderContainer
	textField3.inputAccessoryView = accBar
}

모든 inputView의 inputAccessoryView로 설정한다.


결과

그러면 위와 같이 toobar가 함께 표시되는 것을 확인할 수 있다.


@IBAction func before(_ sender: Any) {
	if textField3.isFirstResponder {
		textField2.becomeFirstResponder()
	} else if textField2.isFirstResponder {
		textField1.becomeFirstResponder()
	}
}
@IBAction func after(_ sender: Any) {
	if textField1.isFirstResponder {
		textField2.becomeFirstResponder()
	} else if textField2.isFirstResponder {
		textField3.becomeFirstResponder()
	}
}

두 버튼을 코드에 action으로 연결하고, 어느 textField인지 조건문으로 분기하도록 하여 포커스를 이동하도록 한다.


결과

툴바의 버튼들을 사용해 포커스를 이동할 수 있다.


 

PassWord Auto Fill


iOS를 사용하다 보면 특정 사이트에서 계정과 비밀번호를 저장하고,
다음에 방문할 때는 직접 입력할 필요 없이 자동으로 입력되는 경우가 있다.

이것을 Safari AutoFill이라고 부른다.
비밀번호의 홍수에서 살고 있는 요즘 아~주 편리한 기능이다.
저장되는 암호는 사용자의 기기에 다시 암호화되어 저장하고, 이것들은 iCloud Keychain을 통해 모든 기기들에 동기화된다.

새로운 암호를 추가하거나 수정해야 하는 경우 iOS의 설정 앱에서 이를 수정할 수 있다.
이러한 Password AutoFill은 iOS 11에서 추가됐고, 앱에서도 Safari AutoRoll과 동일한 기능을 구현할 수 있다.

QuickTypeBar에 표시되는 계정은 앱과 연관된 계정이며,
오른쪽의 열쇠 그림을 선택하면 저장된 모든 비밀번호를 열람할 수 있는 페이지를 표시한다.
Password Manager는 도메인을 기준으로 해당 계정이 앱이나 사이트에 연관된 계정인지 탐색한다.
따라서 이를 구현할 때는 웹사이트와 앱을 연결하는 것이 중요하다.

앱에서는 entiltement파일을 생성해 URL을 저장하고, 앱을 실행할 때마다 이 URL에 접근해 파일을 요청한다.
서버는 앱에서 요청한 파일을 반환해야 하며, 파일명은 apple-app-site-association이고,
웹사이트의 Root 폴더나 wellknown 폴더에 저장해야 한다.

{
	"webcredentials": {
    	"apps": [
        	"blablablabla.page.chillog.AutoFill"
        ]
    }
}

형식은 Json이며 위와 같이 웹사이트와 관련된 app bundle id를 나열한다.
bundle id 앞에는 teamid가 포함되어야 하고, 대소문자를 구분해서 정확히 입력해야 한다.

이런 파일을 전달하면 iOS가 이를 다운로드하고, 저장되어있는 teamid와 bundleid를 대조해,
모든 id가 동일하다면 웹사이트와 앱이 연결되어 있다고 판단해 해당하는 계정을 표시하게 된다.

Password AutoFill을 사용하기 위해선

  • Apple Developer Program에 가입되어 있어야 한다. (유료 계정)
  • 앱과 연결할 Web Server가 있어야 한다.
  • 테스트할 때는 시뮬레이터가 아닌 iCloud Keychain이 활성화돼있는 실제 기기로 테스트해야 한다.

유료 개발자 계정을 사용하고 있지 않으므로 실습이 불가해 내용만 정리한다.

각각의 textField의 contentType을 위와 같이 명시적으로 지정하면,
해당 뷰가 로그인 화면이라는 것을 조금 더 명확하게 판단할 수 있다.
또한 id와 password가 정확한 필드에 입력되도록 유도할 수 있다.

Project 파일의 Signing & Capabilities로 이동한 다음 '+ Capabilities'를 선택 후,
Associated Domains를 찾아 추가한다.

추가되어있는 더미 데이터 대신 웹서버의 도메인으로 변경한다.
이때 접두어와 콜론은 삭제하지 않고 이어서 작성한다.

이제 앱에서는 해당 도메인에서 파일을 요청하게 된다.
해당 파일들은 다음의 URL로 접근 가능해야 한다.

  • url.com/.well-known/apple-app-site-association
  • url.com/apple-app-site-association
{
	"webcredentials": {
    	"apps": [
        	"blablablabla.page.chillog.AutoFill"
        ]
    }
}

해당 URL로 접근했을 때 위와 같은 내용이 담긴 파일에 접근할 수 있어야 한다.

초기 상태에선 비밀번호가 표시되지 않는다.
도메인과 연결되어 확인이 되지만 저장되어있는 비밀번호가 없기 때문이다.
이를 해결하려면 1회 로그인을 실행하거나 설정에서 직접 추가하는 방법이 있다.