기어가더라도 제대로

[UIKit-기초] 텍스트 필드 포커스 이동함에 따라 스크롤뷰 높이 조절(ScrollView Height adjust, 스크롤뷰 높이 조절, UITextField Focus) 본문

UIKit 기초

[UIKit-기초] 텍스트 필드 포커스 이동함에 따라 스크롤뷰 높이 조절(ScrollView Height adjust, 스크롤뷰 높이 조절, UITextField Focus)

Damagucci-juice 2024. 9. 25. 22:37

Why

  • 네이버 밴드의 투표 기능중에 스크롤이 되는 선택지 필드를 구현하고 싶었다.
  • 정말 디테일이 아름답다.

기술 스택

  • 반응형을 위해 Combine
  • UI 편의 선언을 위해 Snapkit, Then

What

  1. 스크롤뷰에 항목이 동적으로 추가되게끔 스택뷰를 선언하고 레이아웃 잡기
  2. 한 텍스트 필드에서 다음 텍스트 필드로 포커스를 옮기기
  3. 키보드 높이 이외의 부분만큼으로 스크롤뷰의 자동 높이 조절

How

1. 스크롤뷰에 항목이 동적으로 추가되게끔 스택뷰를 컨텐트뷰로 선언하고 레이아웃 잡기

  • 스크롤뷰는 뷰의 세이프 에이리어
  • 컨텐트뷰는 스크롤뷰의 “컨텐츠가이드 레이아웃”
    • 너비는 스크롤뷰와 같이
    • 높이는 뷰의 높이보다 크거나 같은데 우선순위는 낮게
class ViewController: UIViewController {

    let scrollView = UIScrollView().then {
        $0.backgroundColor = .systemGray6
    }
    let contentStackView = UIStackView().then {
        $0.axis = .vertical
        $0.distribution = .fill
        $0.alignment = .fill
        $0.spacing = 2
        $0.backgroundColor = .brown
    }

    var cancellables = Set<AnyCancellable>()

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

    private func setupLayout() {
        view.addSubview(scrollView)
        scrollView.addSubview(contentStackView)

                // 스크롤뷰는 뷰의 세이프 에이리어에
        scrollView.snp.makeConstraints { make in
            make.edges.equalTo(view.safeAreaLayoutGuide)
        }

        // 컨텐트뷰는 스크롤뷰의 “컨텐츠가이드 레이아웃”에
        let scrollViewLayoutGuide = scrollView.contentLayoutGuide

        contentStackView.snp.makeConstraints { make in
            make.edges.equalTo(scrollViewLayoutGuide)
            // 너비는 스크롤뷰와 같이
            make.width.equalTo(scrollViewLayoutGuide)
            // 높이는 뷰의 높이보다 크거나 같은데 우선순위는 낮게
            make.height.greaterThanOrEqualTo(view).priority(.low)
        }

        // 3칸 기본 텍스트 필드 만들기
        (1...3).forEach { index in
            let textField = makeTextField(index)
            contentStackView.addArrangedSubview(textField)
        }
    }
}

class RendarableTextField: UITextField {

    let index: Int
    private let height = 50.0

    private(set) var returnDidOccur = PassthroughSubject<Int, Never>()

    init(index: Int) {
        self.index = index
        super.init(frame: .zero)

        // appearance
        self.backgroundColor = .white
        self.text = "\(index): "

        // delegate
        self.delegate = self

        setupLayout()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func setupLayout() {
        self.snp.makeConstraints { make in
            make.width.equalTo(UIScreen.main.bounds.width)
            make.height.equalTo(height)
        }
    }
}

extension RendarableTextField: UITextFieldDelegate { }

 

2. 한 텍스트 필드에서 다음 텍스트 필드로 포커스를 옮기기

  • 키보드가 내려갔다가 올라가지 않게하기 위해서 Return이 발생했을 때 Delegate로 이벤트 발생
  • 마지막 텍스트필드가 리턴을 누른 경우 새로운 텍스트 필드를 추가
// MARK: - ViewController
private func makeTextField(_ index: Int) -> RendarableTextField {
    let textField = RendarableTextField(index: index)
    // binding
    textField.returnDidOccur
        .receive(on: DispatchQueue.main)
        .sink { [weak self] index in
            self?.renderFocusToNext(from: index)
        }
        .store(in: &cancellables)
    return textField
}

private func renderFocusToNext(from index: Int) {
    guard contentStackView.arrangedSubviews.count > index,
          let nextTextField = contentStackView.arrangedSubviews[index] as? RendarableTextField
    else {
        addLastTextField(index)
        return
    }
    nextTextField.becomeFirstResponder()

    func addLastTextField(_ index: Int) {
        let lastTextField = self.makeTextField(index + 1)
        contentStackView.addArrangedSubview(lastTextField)
        lastTextField.becomeFirstResponder()
    }
}

3. 키보드 높이 이외의 부분만큼으로 스크롤뷰의 자동 높이 조절

전체 화면의 높이 = 키보드 영역의 높이 + 나머지 영역 높이

ContentStackView의 높이가 나머지 영역의 높이를 넘어설 때, 즉 컨텐츠스택뷰의 화면이 키보드 영역 밑으로 들어 갈 때 ScrollView의 높이 조절 메서드를 트리거

// ViewController.swift
private var convertedKeyboardFrameEnd: CGRect?

// Keyboard Height
@objc func keyboardWillShow(_ notification: Notification) {
    guard let userInfo = notification.userInfo else { return }
    guard let screen = notification.object as? UIScreen,
          let keyboardFrameEnd = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }

    let fromeCoordinateSpace = screen.coordinateSpace
    let toCoordinateSpace: UICoordinateSpace = view
    self.convertedKeyboardFrameEnd = fromeCoordinateSpace.convert(keyboardFrameEnd, to: toCoordinateSpace)
    print(screen.bounds.height)
}

private func handleAddTextField(_ vm: PollTextFieldViewModel) {
    let newTextField = PollTextFieldView(vm: vm)
    self.textViews.append(newTextField)
    textFieldStackView.addArrangedSubview(newTextField)
    revealLastTextField()

    self.view.layoutIfNeeded()

    func revealLastTextField() {
        let statusBarHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0.0
        let navigationBarHeight = navigationController?.navigationBar.bounds.height ?? 0.0
        let stackViewBottomOffset = textFieldStackView.frame.origin.y + textFieldStackView.frame.height + statusBarHeight + navigationBarHeight

        if let convertedKeyboardFrameEnd, stackViewBottomOffset > convertedKeyboardFrameEnd.origin.y {
            let targetHeight = stackViewBottomOffset - convertedKeyboardFrameEnd.origin.y
            // ScrollView 높이 조절 메서드
            scrollView.setContentOffset(.init(x: 0, y: targetHeight), animated: true)
        }
    }
}

최종 코드

Comments