이번 포스팅에선 다음과 같은 DropDown View를 구현해 보자.
이후, UIResponder의 firstResponder를 활용해 리팩토링했습니다.
리팩토링한 포스팅을 아래 링크에 있습니다.
https://seokyoungg.tistory.com/85
전체 코드는 여기에서 확인해 볼 수 있다.
DropDown TableView
우선 TableView의 구현에 앞서 DropDown TableViewCell부터 구현해 보자.
Cell은 선택되었을 때, 선택이 되지 않았을 때 텍스트 컬러가 달라야 하므로
isSelected를 다음과 같이 오버라이드 해주자.
// DropDownCell.swift
override var isSelected: Bool {
didSet {
optionLabel.textColor = isSelected ? .black : .systemGray2
}
}
// MARK: - UI Components
private let optionLabel: UILabel = {
let label = UILabel()
return label
}()
TableView의 경우는 DropDown의 아이템에 따라,
Height가 변해야 하기 때문에,
다음과 같이 구현해 주자.
// DropDownTableView.swift
private let minHeight: CGFloat = 0
private let maxHeight: CGFloat = 192
override public func layoutSubviews() {
super.layoutSubviews()
if bounds.size != intrinsicContentSize {
invalidateIntrinsicContentSize()
}
}
override public var intrinsicContentSize: CGSize {
layoutIfNeeded()
if contentSize.height > maxHeight {
return CGSize(width: contentSize.width, height: maxHeight)
} else if contentSize.height < minHeight {
return CGSize(width: contentSize.width, height: minHeight)
} else {
return contentSize
}
}
DropDownView
이제 본격적으로 DropDownView를 구현해 보자.
final class DropDownView: UIView
재사용성의 편의성을 위해 DropDownView를 띄우는 기준이 되는 View인 AnchorView를
외부에서 설정할 수 있도록 다음과 같은 변수를 추가해 주자.
weak var anchorView: UIView?
다음으로 DropDownTableView를 내부에 생성해 주고,
이벤트를 보낼 listener와, dataSource와
선택된 옵션을 외부에 알려주는 selectedOption을 생성해 주자.
// DropDownView.swift
private let dropDownTableView = DropDownTableView()
/// DropDown에서 아이템이 선택될 경우, 이벤트를 보내는 곳
weak var listener: DropDownListener?
/// DropDown의 아이템 리스트
var dataSource = [String]() {
didSet { dropDownTableView.reloadData() }
}
/// DropDown의 현재 선택된 항목을 알 수 있습니다.
private(set) var selectedOption: String?
TableView Delegate & DataSource
다음으로, DropDownTableView의 Delegate와 DataSource를 DropDownView로 지정한다.
// DropDownView.swift
init() {
...
dropDownTableView.delegate = self
dropDownTableView.dataSource = self
}
이후, delegate와 dataSource의 메서드를 구현해 준다.
// MARK: - UITableViewDataSource
extension DropDownView: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return dataSource.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: DropDownCell.identifier,
for: indexPath
) as? DropDownCell
else {
return UITableViewCell()
}
/// selectedOption이라면 해당 cell의 textColor가 바뀌도록
if let selectedOption = self.selectedOption,
selectedOption == dataSource[indexPath.row] {
cell.isSelected = true
}
cell.configure(with: dataSource[indexPath.row])
return cell
}
}
// MARK: - UITableViewDataSource
extension DropDownView: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
selectedOption = dataSource[indexPath.row]
listener?.dropdown(self, didSelectRowAt: indexPath)
dropDownTableView.selectRow(at: indexPath)
isDisplayed = false
}
}
DropDown Display & Hide
이제 DropDown을 외부에 Display 하기 위해서 Constraints를 잡아야 한다.
view에 추가 + isHidden (실패)
초기에 시도했던 방법은 DropDownTableView를 init단계에서
DropDownView에 추가해 주고 Layout을 잡는 거였다.
// MARK: - Initializers
init() {
super.init(frame: .zero)
self.addSubview(dropDownTableView)
dropDownTableView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
...
}
func displayDropDown() {
dropDownTableView.isHidden = true
}
func hideDropDown() {
dropDownTableView.isHidden = false
}
하지만 해당 방식은 다음과 같이 addSubview의 순서에 따라 DropDownView가 가려진다는 이슈가 발생했다.
Window에 추가 + removeFromSuperView (성공)
앞서 View가 가져지는 문제가 발생했기에,
View의 최상위 즉, 가장 SubView에 추가할 필요가 있었다.
addSubview메서드는 뷰 계층에 가장 하위 계층에 view를 추가하기에,
해당 view의 window에 추가해야 한다.
또한, DropDownView가 display 될 때, 다른 View들과 겹치지 않게
동적으로 addSubView를 할 필요가 있다.
정리하자면, 다음과 같다.
- UIWindow에 View를 추가해야 하고,
- View를 추가할 때, 필요한 시점에 동적으로 추가해야 한다.
우선, 외부에서 Constraints를 정할 수 있도록 다음과 같이 선언해 주자.
/// DropDown을 띄울 Constraint를 적용합니다.
private var dropDownConstraints: ((ConstraintMaker) -> Void)?
func setConstraints(_ closure: @escaping (_ make: ConstraintMaker) -> Void) {
self.dropDownConstraints = closure
}
ViewController에선 Snapkit과 같이 사용할 수 있다.
// ViewController.swift
dropDownView.setConstraints { make in
...
}
다음으로 display 하고 hide 하는 메서드를 추가해 주자.
/// DropDownList를 보여줍니다.
func displayDropDown(with constraints: ((ConstraintMaker) -> Void)?) {
guard let constraints = constraints else { return }
window?.addSubview(dropDownTableView)
dropDownTableView.snp.makeConstraints(constraints)
}
/// DropDownList를 숨김니다.
func hideDropDown() {
dropDownTableView.removeFromSuperview()
dropDownTableView.snp.removeConstraints()
}
마지막으로 외부에서 쉽게 display와 hide 할 수 있도록 bool 변수를 추가해 준다.
/// DropDown을 display할지 결정합니다.
var isDisplayed: Bool = false {
didSet {
isDisplayed ? displayDropDown(with: dropDownConstraints) : hideDropDown()
}
}
DropDownListener
이를 사용하게 되는 ViewController에서는 다음과 같은 로직이 필요하다.
- DropDownView의 listener를 자기 자신으로 등록
- DropDownView외부를 터치했을 경우, DropDown이 hide 되어야 하는 로직
매번 ViewController에 작성해야 하기에, 이를 DropDownListener를 통해 구현해 주었다.
다음과 같이 protocol을 선언해 주고,
protocol DropDownListener: AnyObject {
var dropDownViews: [DropDownView]? { get set }
func hit(at hitView: UIView)
func registerDropDrownViews(_ dropDownViews: DropDownView...)
func dropdown(_ dropDown: DropDownView, didSelectRowAt indexPath: IndexPath)
}
해당 protocol을 ViewController 제약조건을 걸어주어 Extension을 한다.
public extension DropDownListener where Self: UIViewController
다음 extension내부에 registerDropDownViews를 구현해 주고,
func registerDropDrownViews(_ dropDownViews: DropDownView...) {
self.dropDownViews = dropDownViews
dropDownViews.forEach { $0.listener = self }
}
화면 외부를 터치했을 때, DropDown이 hide 되는 로직을 구현한다.
func hit(at hitView: UIView) {
dropDownViews?.forEach { view in
if
view.anchorView === hitView {
view.isDisplayed.toggle()
} else {
view.isDisplayed = false
}
}
}
또한, 추가적으로 isUserInteractEnabled가 false인 경우 터치 이벤트가 전달이 안되기 때문에,
DropDownView에 다음과 같이 구현해 준다.
// DropDownView.swift
weak var anchorView: UIView? {
didSet {
anchorView?.isUserInteractionEnabled = true
}
}
사용법
사용법은 다음과 같다.
우선 ViewController 내부에서 DropDownView와 AnchorView를 생성해 준다.
이후, registerDropDownViews 메서드를 호출해 준다.
// MARK: - ViewController.swift
private let dropDownView = DropDownView()
private let label: UILabel = {
let label = UILabel()
label.layer.borderColor = UIColor.systemGray2.cgColor
label.layer.borderWidth = 1
label.layer.cornerRadius = 12
return label
}()
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
registerDropDrownViews(dropDownView)
}
그다음, DropDownView의 anchorView를 지정해 주고,
dataSource를 전달한다.
이후, setConstraints 메서드를 통해 DropDownView의 Constraints를 지정한다.
// ViewController.swift
func setDropDown() {
dropDownView.anchorView = label
dropDownView.dataSource = ["신라면", "진라면", "참깨라면"]
}
func setConstraints() {
label.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.equalTo(200)
make.height.equalTo(50)
}
dropDownView.setConstraints { [weak self] make in
guard let self else { return }
make.leading.trailing.equalTo(label)
make.top.equalTo(label.snp.bottom)
}
}
마지막으로
touchesBegan을 오버라이드하여 hitView를 hit메서드를 통해 전달해 주면 된다.
// ViewController.swift
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
guard
let touch = touches.first,
let hitView = self.view.hitTest(touch.location(in: view), with: event)
else { return }
self.hit(at: hitView)
}
References
'iOS > iOS' 카테고리의 다른 글
[iOS] Render Loop & Hitch(1) (0) | 2024.02.22 |
---|---|
[iOS] UIResponder(1) (1) | 2023.11.10 |
[iOS] LocalDB(3) - Core Data CRUD (0) | 2023.10.25 |
[iOS] Local DB(2) - Core Data Concept (0) | 2023.10.09 |
[iOS] Local DB(1) - UserDefaults, Keychain (0) | 2023.10.04 |