article thumbnail image
Published 2023. 10. 31. 00:18

이번 포스팅에선 다음과 같은 DropDown View를 구현해 보자.

 

전체 코드는 여기에서 확인해 볼 수 있다.

 

 

 

 

 

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 (실패)

초기에 시도했던 방법은 DropDownTableViewinit단계에서

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

https://github.com/AssistoLab/DropDown

'iOS > iOS' 카테고리의 다른 글

[iOS] Render Loop & Hitch(1)  (0) 2024.02.22
[iOS] UIResponder(1)  (0) 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
복사했습니다!