본문 바로가기
iOS/UI

iOS UIScrollView 키보드 show/hide 시 스크롤 처리

by 바등쪼 2023. 3. 15.

UIScrollView를 사용해 폼 형식의 뷰를 구현하다 보면 사용자가 입력을 할 때 생기는 키보드 만틈 뷰를 올려줘야 하는 상황이 발생한다.

즉, 사용자가 TextField를 터치하여 입력을 시작하면 ScrollView가 키보드의 높이만큼 자동으로 스크롤되어 사용자가 입력중인 TextField가 키보드에 가려지지 않도록 해야한다.

사실 이 문제를 해결하는 코드는 구글링을 하면 많이 나오고 복붙하면 바로 사용이 가능하다. 하지만, 이번에 구현한 방식은 흔히 사용하는 코드를 Protocol로 만들어서 재사용성을 높인 방식이다. Keyboard의 등장에 따른 UI 처리는 여러 VC에서 중복으로 필요한 경우가 많고 이럴 때 마다 필요한 코드를 매번 VC에 넣게되면 VC의 크기가 커지게 된다. 이는 우리가 지양해야 하는 방향이다. 

 

Keyboard 처리 코드의 이해

우선 구글링을 통해 쉽게 찾을 수 있는 코드들을 이해해보자

  1. NotificationCenter를 통해 키보드의 show와 hide의 이벤트를 받아온다.
  2. keyboardWillShowNotification이 발생하면 scrollView의 contentInset과 scrollIndicatorInsets를 키보드의 높이 만큼 지정한다.
  3. keyboardWillHideNotification이 발생하면 앞서 넣어준 inset들을 다시 .zero로 지정한다.

이 코드들을 재사용성 있게 구현해보고 싶었다.

 

일반적으로 Swift에서 이러한 재사용성을 높이기 위한 방법을 여러가지가 있다.

  1. 상속
  2. 클래스나 구조체의 Extension
  3. 프로토콜의 Extension

대표적으로 이러한 방법들이 있는데, 이번 사례에서는 UIScrollView가 존재하는 VC에서만 키보드에 반응하는 기능을 넣고 싶었기 때문에 상속이나 클래스 Extension에 이러한 코드를 넣는 것은 맞지 않다고 생각했다. 이러한 기능이 필요하지 않은 VC에서도 영향을 끼칠 수 있기 때문이다.

그렇다면 이러한 부분적 기능의 추가는 프로토콜의 Extension으로 구현하면 좋을 것 같다!

 

구현

Swift에서는 Protocol로 해당 구현체가 구현해야 할 명세를 정할 수 있다. 단순히 구현체에 구현을 맡기는 것 뿐만 아니라 Extension을 활용하여 기본 구현을 미리 Protocol에 생성하여 이를 채택하면 별도의 구현 없이 바로 호출할 수 있도록 할 수 있다. 관련 내용은 프로토콜 지향 프로그래밍 (POP)과 연결된다.

우선 이 프로토콜의 이름을 KeyboardReactable로 정했다. 연습삼아 RxSwift를 사용한 버전과 Combine을 사용한 버전 2가지를 만들어 보았다..!

소소하게 키보드 영역 밖을 터치하면 키보드가 내려가는 함수도 추가했다..ㅎㅎ


RxSwift를 이용한 KeyboardReactable

import UIKit

import RxCocoa
import RxSwift

// 키보드의 등장에 반응하여 ScrollView UI 변화
protocol KeyboardReactable: AnyObject {
  var scrollView: UIScrollView! { get set }
  var loadBag: DisposeBag { get set }
}

extension KeyboardReactable where Self: UIViewController {
  /// 키보드가 올라와 있는 상황에서 키보드 밖의 영역을 터치하면 키보드가 사라지도록 동작
  func setTapGesture() {
      let tap = UITapGestureRecognizer(target: view, action: #selector(UIView.endEditing))
      tap.cancelsTouchesInView = false
      view.addGestureRecognizer(tap)
  }
  
  /// 키보드가 올라간 만큼 화면도 같이 스크롤
  func setKeyboardNotification() {
    let keyboardWillShow = NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification)
    let keyboardWillHide = NotificationCenter.default.rx.notification(UIResponder.keyboardWillHideNotification)

    keyboardWillShow
      .asDriver(onErrorRecover: { _ in .never()})
      .drive(onNext: { [weak self] noti in
        self?.handleKeyboardWillShow(noti)
      }).disposed(by: loadBag)
    
    keyboardWillHide
      .asDriver(onErrorRecover: { _ in .never()})
      .drive(onNext: { [weak self] noti in
        self?.handleKeyboardWillHide()
      }).disposed(by: loadBag)
  }
  
  private func handleKeyboardWillShow(_ notification: Notification) {
      guard let userInfo = notification.userInfo,
          let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
              return
      }

      let contentInset = UIEdgeInsets(
          top: 0.0,
          left: 0.0,
          bottom: keyboardFrame.size.height,
          right: 0.0)
      scrollView.contentInset = contentInset
      scrollView.scrollIndicatorInsets = contentInset
  }

  private func handleKeyboardWillHide() {
      let contentInset = UIEdgeInsets.zero
      scrollView.contentInset = contentInset
      scrollView.scrollIndicatorInsets = contentInset
  }
}

 

Combine 버전

import UIKit
import Combine

/// ScrollView를 가지고 있는 VC에서 키보드가 올라오면 이를 감지하고 화면의 위치를 자동 조절
protocol KeyboardReactable: AnyObject {
  var scrollView: UIScrollView! { get set }
  var cancelBag: Set<AnyCancellable> { get set }
}

extension KeyboardReactable where Self: UIViewController {
  func setTapGesture() {
      let tap = UITapGestureRecognizer(target: view, action: #selector(UIView.endEditing))
      tap.cancelsTouchesInView = false
      view.addGestureRecognizer(tap)
  }
  
  // 키보드가 올라간 만큼 화면도 같이 스크롤
  func setKeyboardNotification() {
    let keyboardWillShow = NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
    let keyboardWillHide = NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)
    
    
    keyboardWillShow.sink { noti in
      self.handleKeyboardWillShow(noti)
    }.store(in: &cancelBag)
    
    keyboardWillHide.sink { noti in
      self.handleKeyboardWillHide()
    }.store(in: &cancelBag)
  }
  
  private func handleKeyboardWillShow(_ notification: Notification) {
      guard let userInfo = notification.userInfo,
          let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
              return
      }

      let contentInset = UIEdgeInsets(
          top: 0.0,
          left: 0.0,
          bottom: keyboardFrame.size.height,
          right: 0.0)
      scrollView.contentInset = contentInset
      scrollView.scrollIndicatorInsets = contentInset
  }

  private func handleKeyboardWillHide() {
      let contentInset = UIEdgeInsets.zero
      scrollView.contentInset = contentInset
      scrollView.scrollIndicatorInsets = contentInset
  }
}

 

구현 설명

  • 우선 Class만 이 프로토콜을 채택할 수 있도록 AnyObject를 채택했다.
  • scrollView가 필요하기 때문에 scrollView를 프로토콜에 명시했다. (현재 프로젝트에서 Storyboard를 사용하기 때문에 묵시적 언래핑(IUO)으로 지정했지만 코드 베이스로 구현 중이라면 !를 제거하면 된다.)
  • RxSwift나 Combine을 사용하기 때문에 스트림을 담을 DisposeBag을 포함시켰다.
  • 이제 Protocol을 확장(extension)시켜 기본 구현체를 구현한다.
  • setTapGesture()는 키보드 밖을 터치하면 키보드가 내려가도록 한다.
  • setKeyboardNotification()은 NotifivationCenter를 통해 키보드 show/hide 이벤트를 받아온다.
  • handleKeyboardWillShow() 와 handleKeyboardWillHide()함수로 이러한 이벤트에 맞게 scrollView의 inset을 조정한다.

RxSwift나 Combine을 사용한 이유

사실 구글링을 해서 얻은 코드들은 NotificationCenter에 직접 Selector를 추가하여 @objc 함수로 지정된 함수가 키보드 이벤트를 처리하도록 하고 있다. 이 코드들을 그대로 프로토콜에 넣으면 에러가 발생한다.

 

Why? 프로토콜에는 @objc 함수가 포함될 수 없기 때문이다.

 

사실 이 에러를 보고 이 기능은 프로토콜로 구현이 불가능한 줄 알았...으나! Combine을 공부하다가 NotificationCenter 이벤트를 Combine으로 처리할 수 있다는 사실이 기억이 났다.

 

키보드 이벤트를 Publisher로 만들고 Selector가 아닌 Publisher 구독을 통해 처리하도록 하여 Protocol에서 원하는 기능을 넣을 수 있었다.

Rx 역시 마찬가지로 구현 할 수 있기 때문에 단순 번역 작업을 진행하였다.

 

사용법

class SignUpVC: UIViewController {
	let scrollView = UIScrollView()
 	var disposeBag = DisposeBag()
 	
    public override func viewDidLoad() {
        super.viewDidLoad()
        self.setTapGesture()
        self.setKeyboardNotification()
        // 생략
    }
}

extension SignUpVC: KeyboardReactable {}

 

VC에서 스크롤 뷰의 이름을 다르게 하고 싶으면 다음과 같이 작성하면 된다.

 

class SignUpVC: UIViewController {
	let myScrollView = UIScrollView()
 	var disposeBag = DisposeBag()
    
    lazy var scrollView: UIScrollView() = myScrollView
 	
    public override func viewDidLoad() {
        super.viewDidLoad()
        self.setTapGesture()
        self.setKeyboardNotification()
        // 생략
    }
}

extension SignUpVC: KeyboardReactable {}

 

키보드 이벤트 말고도 프로토콜의 확장을 통해 다양한 기능들을 구현할 수 있을 것 같다. 특히, BaseViewController를 만들어서 이를 상속하여 VC를 구현하는 코드들을 많이 봤는데 이 방식을 가능하면 프로토콜로 나누어 합성을 통해 VC에 추가하면 보다 효율적일 것 같다는 생각이 든다.

 

댓글