관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 2. 키보드 처리 - 키보드가 올라갈 때 스크롤 뷰를 올리는 UI 본문

UI 컴포넌트 (swift)

[iOS - swift] 2. 키보드 처리 - 키보드가 올라갈 때 스크롤 뷰를 올리는 UI

jake-kim 2023. 7. 11. 02:01

1. 키보드 처리 - 키보드가 올라갈 때 뷰를 올리는 UI

2. 키보드 처리 - 키보드가 올라갈 때 스크롤 뷰를 올리는 UI

키보드가 올라갈 때 스크롤 뷰를 올리는 UI

구현한 뷰

구현 아이디어

  • keyboard를 감싸는 투명 UIView, keyboard 바로 위쪽을 감싸는 투명 UIView를 준비
    • 투명 UIView는 hitTest를 사용하여 pass through하게 구현 (PassThroughView 구현은 이전 포스팅 글 참고)
  • 키보드 바로 위쪽을 감싸는 투명 UIView위에 UIScrollView + UIStackView를 삽입
  • UIStackView에 UITextView, UIButton을 넣으면 버튼이 화면 하단으로 가지 않고 중간에 있을것이므로, UITextView와 UIButton 중간 여백을 넣어주는 spacerView까지 추가
  • 키보드가 올라올 때 UIScrollView의 frame.height값이 작아지는데 스크롤 뷰의 스크롤 위치는 그대로이므로, 그 타이밍에 스크롤을 맨 아래로 내리는 작업이 필요 (= 스크롤 뷰의 콘텐츠도 위로 밀리는 효과를 주기 위함)

구현

  • 사용한 라이브러리
pod 'SnapKit'
pod 'Then'
pod 'RxSwift'
pod 'RxCocoa'
pod 'RxGesture'
  • 상속보다는 유지보수에 용이한 프로토콜 형태인 KeyboardWrapperable 구현
    • 해당 프로토콜을 채택하는 UIViewController에서 사용할 수 있도록 구현
    • didChangeKeyboardHeight 값은 이 프로토콜을 사용하는 쪽에서 키보드의 위치가 변경될 때 받아서 스크롤뷰의 스크롤을 맨 래로 이동시키고, UITextView와 UIButton 중간에 있는 spacerView를 숨겨지거나 보여지게 하기 위함
protocol KeyboardWrapperable {
    var keyboardWrapperView: PassThroughView { get }
    var keyboardSafeAreaView: PassThroughView { get }
    var disposeBag: DisposeBag { get }
    var didChangeKeyboardHeight: ((CGFloat) -> Void)? { get }
    
    func setupKeybaordWrapper()
}
  • 이후 KeyboardWrapperable의 extension구현은 이전 포스팅 글에서 알아본 과정과 동일

(완성된 KeyboardWrapperable)

import UIKit
import SnapKit
import RxSwift
import RxCocoa

private struct AssociatedKeys {
    static var isEnabled = "isEnabled"
}

protocol KeyboardWrapperable {
    var keyboardWrapperView: PassThroughView { get }
    var keyboardSafeAreaView: PassThroughView { get }
    var disposeBag: DisposeBag { get }
    var didChangeKeyboardHeight: ((CGFloat) -> Void)? { get }
    
    func setupKeybaordWrapper()
}

extension KeyboardWrapperable where Self: UIViewController {
    private var isEnabled: Bool {
        get {
            (objc_getAssociatedObject(self, &AssociatedKeys.isEnabled) as? Bool) ?? false
        }
        set {
            objc_setAssociatedObject(self, &AssociatedKeys.isEnabled, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }

    func setupKeybaordWrapper() {
        guard !isEnabled else { return }
        isEnabled.toggle()

        setupLayout()
        observeKeyboardHeight()
    }
    
    private func setupLayout() {
        view.addSubview(keyboardWrapperView)
        view.addSubview(keyboardSafeAreaView)
        
        keyboardWrapperView.snp.makeConstraints {
            $0.leading.trailing.bottom.equalToSuperview()
            $0.height.equalTo(0).priority(.high)
        }

        keyboardSafeAreaView.snp.makeConstraints {
            $0.top.leading.trailing.equalToSuperview()
            $0.bottom.equalTo(keyboardWrapperView.snp.top)
        }
    }

    private func observeKeyboardHeight() {
        Observable<(Bool, Notification)>
            .merge(
                NotificationCenter.default.rx.notification(UIResponder.keyboardWillShowNotification)
                    .map { notification in (true, notification) },
                NotificationCenter.default.rx.notification(UIResponder.keyboardWillHideNotification)
                    .map { notification in (false, notification) }
            )
            .bind(with: self) { ss, tuple in
                let (isKeyboardUp, notification) = tuple
                let uesrInfo = notification.userInfo
                guard let endFrame = uesrInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
                
                let endFrameMinY = endFrame.origin.y
                let shownKeyboardHeight = isKeyboardUp ? endFrame.height : 0
                ss.didChangeKeyboardHeight?(shownKeyboardHeight)
                
                ss.keyboardWrapperView.snp.updateConstraints {
                    $0.height.equalTo(shownKeyboardHeight).priority(.high)
                }
                UIView.transition(
                    with: ss.keyboardWrapperView,
                    duration: 0.25,
                    options: .init(rawValue: 458752),
                    animations: ss.view.layoutIfNeeded
                )
            }
            .disposed(by: disposeBag)
    }
}

 사용하는쪽

  • KeyboardWrappable
  • 가장 중요한 부분은 Metric으로 interSpacing 값 (UITextView와 UIButton 사이에 들어갈 spacerView의 높이가 될 값) 계산을 해놓는 것
import UIKit
import Then
import SnapKit
import RxSwift

class ViewController: UIViewController, KeyboardWrapperable {
    private enum Policy {
        static let countOfText = 700
    }
    private enum Metric {
        static let scrollViewTopSpacing = 30.0
        static let textViewHeight = UIScreen.main.bounds.height * 0.5
        static let stackViewSpacing = 10.0
        static let spacing = 30.0
        static let buttonHeight = 80.0
        static let bottomSpacing = 30.0
        static var interSpacing: CGFloat {
            let safeInset = UIApplication.shared.windows.first?.safeAreaInsets ?? .zero
            return UIScreen.main.bounds.height - (scrollViewTopSpacing + textViewHeight + buttonHeight + bottomSpacing + safeInset.top + safeInset.bottom)
        }
    }
    ...
    
    var keyboardWrapperView = PassThroughView()
    var keyboardSafeAreaView = PassThroughView()
    var didChangeKeyboardHeight: ((CGFloat) -> Void)?
    
    ...
}
  • keyboardSafeView에 scrollView를 넣고 scrollView안에 stacView를 넣어서 구현
keyboardSafeAreaView.addSubview(scrollView)
scrollView.addSubview(stackView)
stackView.addArrangedSubview(textView)
stackView.addArrangedSubview(interSpacerView)
stackView.addArrangedSubview(button)
stackView.addArrangedSubview(bottomSpacerView)
  • 레이아웃
scrollView.snp.makeConstraints {
    $0.top.equalToSuperview().inset(Metric.scrollViewTopSpacing)
    $0.leading.trailing.equalToSuperview()
    $0.bottom.equalToSuperview()
}
stackView.snp.makeConstraints {
    $0.top.equalToSuperview().priority(.medium)
    $0.leading.trailing.width.equalToSuperview()
    $0.bottom.equalToSuperview().priority(.high)
}
textView.snp.makeConstraints {
    $0.leading.trailing.equalToSuperview().inset(30)
    $0.height.equalTo(Metric.textViewHeight)
}
interSpacerView.snp.makeConstraints {
    $0.height.equalTo(Metric.interSpacing)
}
button.snp.makeConstraints {
    $0.leading.trailing.equalToSuperview()
    $0.height.equalTo(Metric.buttonHeight)
}
bottomSpacerView.snp.makeConstraints {
    $0.height.equalTo(Metric.bottomSpacing)
}
  • KeyboardWrapperable에서 키보드의 위치가 변경될때마다 호출되는 didChangeKeyboardHeight 클로저에 원하는 작업을 입력
    • interSpacerView의 hidden 처리 (키보드가 올라올때 interSpacerView를 안보이게끔 처리)
    • scrollToBottom 처리 (키보드의 위치가 변경될때마다 스크롤을 맨 아래로 내려야, 스크롤 뷰도 같이 올라가거나 내려가는것처럼 보이기 때문)
didChangeKeyboardHeight = { [weak self] height in
    guard let self else { return }
    print(height)
    interSpacerView.isHidden = height != 0
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
        self.scrollToBottom()
    })
}

func scrollToBottom() {
    let bottomOffset = CGPoint(x: 0, y: scrollView.contentSize.height - scrollView.bounds.size.height)
    scrollView.setContentOffset(bottomOffset, animated: true)
}

(완성)

구현한 뷰

* 전체 코드: https://github.com/JK0369/ExKeybaordWithInputView

Comments