| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- swift 백준
- task cancellation
- identifiable
- coordinator
- UIKit
- custombottomsheet
- DP
- swift concurrency
- swift navigationcontroller
- uikit toast
- swift 점선
- RxSwift
- BFS
- task cancel
- Tuist
- scene delegate
- 버튼 피드백
- swift opaque type
- OAtuh 2.0
- swift existential type
- reactorkit
- swift bottomsheet
- custom navigation bar
- swift dashed line
- 드롭다운 버튼
- SWIFT
- swift custom ui
- custom ui
- swift associated type
- button configuration
- Today
- Total
김경록의 앱 개발 여정
[Swift UIKit] 유저 사용성을 고려한 UI의 재설계 과정 (드롭 다운 버튼) 본문
개요
신규 프로젝트에서 사유 선택을 위한 드롭다운 버튼이 필요했다.
드롭다운 버튼이라는 게 사실 앱보다는 웹 환경에서 더 자주 쓰이는 UI 같지만,
이번 프로젝트의 디자인은 웹 UI를 앱에 맞게 리디자인하는 과정이었기 때문에
가능한 한 동기화를 시키려고 했다.
처음 해보는 작업이라 구글링을 해봤는데, 대부분 서드파티 라이브러리를 이용해서 해결하고 있었다.
하지만 이번 프로젝트는 SnapKit 외에는 서드파티를 쓰지 않는 것을 희망해서 배제하기로 했다
우선 가장 간단해 보이는 방법을 찾았고, iOS 기본 컴포넌트인 UIMenu로 구현을 시작했다.
UIMenu가 뭐지?

UIMenu는 버튼을 탭 했을 때 메뉴 리스트를 보여주는 iOS 기본 컴포넌트다.
iOS 14 이후부터는 버튼과 쉽게 결합할 수 있도록 API가 강화되어서,
옵션을 나열하는 경우 꽤 간단하게 적용할 수 있었다.
공식 문서 설명에 따르면, UIMenu는 액션 모음을 정의하고 표시하는 방식이다.
덕분에 별도의 커스텀 뷰 없이도 빠르게 드롭다운과 유사한 UI를 구성할 수 있다.
간단히 말해, 이런 식으로 버튼에 붙여서 사용할 수 있다:
let items = [
("육아휴직", NonWorkingType.parentalLeave),
("출산전후휴가", NonWorkingType.maternityLeave),
("배우자출산휴가", NonWorkingType.spouseMaternityLeave)
]
let actions = items.map { item in
UIAction(title: item.0) { _ in
print("Selected \(item.1)")
}
}
let button = UIButton(type: .system)
button.menu = UIMenu(children: actions)
button.showsMenuAsPrimaryAction = true
UIMenu로 만든 결과물

처음 만든 결과물은 동작 자체는 잘 됐다.
리스트 항목을 누르면 값이 잘 선택되고, 서버와의 매핑도 문제없었다.
스크린샷만 봐도 알 수 있듯이, 메뉴 항목이 많은 환경에서는 한눈에 보기 불편했다.
일반적인 화면에선 스크롤 없이 리스트들이 나열됐고
작은 화면(iPhone SE 같은 극단적인 케이스)에서는 스크롤을 지원했다.
메뉴 자체의 높이를 줄여 공통적으로 스크롤을 유도하면 깔끔할 것 같았지만 그 부분은 구현에 실패했다.
즉, UIMenu는 단순히 항목이 몇 개 없는 상황이라면 충분히 쓸 만했지만,
항목이 많아지는 순간 UX가 급격히 떨어지는 한계를 드러냈다.
개선하기
그래서 다른 접근을 고민하다가,
아마 보통 iPad에서 많이 쓰이는 UI인 걸로 알고 있지만
UIPopoverPresentationController + UITableView 조합을 선택했다.
애플의 HIG에서도 팝오버는 큰 화면에서 주변 맥락을 유지하면서 가볍게 보여주는 UI로 소개되고 있다.
보통 iPhone 같은 Compact 환경에서는 보통 풀스크린이나 시트로 대체되는 것이 자연스럽다는 얘기다.
그래서 그런가?
기본 동작은 Compact width에서 자동으로 풀스크린 적응(adaptive)이 되어버린다.
이 적응을 막고 싶다면 UIPopoverPresentationControllerDelegate에서
아래처럼 .none을 반환해 주면 된다.
func adaptivePresentationStyle(
for controller: UIPresentationController,
traitCollection: UITraitCollection
) -> UIModalPresentationStyle {
return .none
}
- Popover를 사용하면 메뉴가 화면을 가득 채우지 않고 자연스럽게 떠오른다.
- 그 안에 TableView를 넣어주면, 스크롤 가능하고 체크마크도 표시할 수 있다.
- 게다가 maxHeightFraction 같은 방식을 활용해서, 화면 크기에 맞춰 동적 높이 조절도 가능하다.
그래서 최종적으로 아래와 같이 너무 마음에 들게 개선할 수 있었다

import UIKit
final class DropdownButton: UIButton {
// Public item representation (ID is Hashable for external mapping)
public struct Item: Hashable {
public let id: AnyHashable
public let title: String
public init(id: AnyHashable, title: String) {
self.id = id
self.title = title
}
}
// MARK: Public State / Callbacks
public private(set) var selectedTitle: String?
public private(set) var selectedId: AnyHashable?
/// Called whenever a user selects an item.
public var onSelect: ((Item) -> Void)?
/// Accessibility and layout customization
public var placeholder: String = "선택" {
didSet { applyTitleIfNeeded() }
}
public var maxHeightFraction: CGFloat = 0.4 // e.g., 0.4 == 40% of screen height
public var rowHeight: CGFloat = 48
public var minimumPopoverWidth: CGFloat = 230
public var cellNumberOfLines: Int = 1
// MARK: Internal Data
public private(set) var items: [Item] = [] {
didSet { isEnabled = !items.isEmpty }
}
// MARK: Lifecycle
public override init(frame: CGRect) {
super.init(frame: frame)
configureButtonAppearance()
applyTitleIfNeeded()
addTarget(self, action: #selector(didTap), for: .touchUpInside)
isAccessibilityElement = true
accessibilityTraits.insert(.button)
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
configureButtonAppearance()
applyTitleIfNeeded()
addTarget(self, action: #selector(didTap), for: .touchUpInside)
isAccessibilityElement = true
accessibilityTraits.insert(.button)
}
// MARK: Public API
/// Replace entire data source.
public func setItems(_ newItems: [Item]) {
items = newItems
// If the previous selection no longer exists, reset to placeholder
if let selectedId = selectedId, items.contains(where: { $0.id == selectedId }) == false {
resetSelection()
} else {
// Keep the current title if still valid
applyTitleIfNeeded()
}
}
/// Preselect by item identifier.
public func preselect(id: AnyHashable) {
guard let found = items.first(where: { $0.id == id }) else { return }
applySelection(item: found)
}
/// Preselect by display title.
public func preselect(title: String) {
guard let found = items.first(where: { $0.title == title }) else { return }
applySelection(item: found)
}
/// Reset selection and show placeholder.
public func resetSelection() {
selectedTitle = nil
selectedId = nil
applyTitle(placeholder)
}
// MARK: Appearance
private func configureButtonAppearance() {
var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = .systemGray6
configuration.baseForegroundColor = .label
configuration.cornerStyle = .medium
configuration.titleLineBreakMode = .byTruncatingTail
let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: 12, weight: .medium)
configuration.image = UIImage(systemName: "chevron.down", withConfiguration: symbolConfiguration)
configuration.imagePlacement = .trailing
configuration.imagePadding = 6
configuration.attributedTitle = AttributedString(placeholder, attributes: AttributeContainer([
.font: UIFont.preferredFont(forTextStyle: .body)
]))
configuration.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)
self.configuration = configuration
accessibilityLabel = placeholder
}
private func applyTitleIfNeeded() {
applyTitle(selectedTitle ?? placeholder)
}
private func applyTitle(_ title: String) {
guard var buttonConfiguration = configuration else { return }
buttonConfiguration.attributedTitle = AttributedString(title, attributes: AttributeContainer([
.font: UIFont.preferredFont(forTextStyle: .body)
]))
configuration = buttonConfiguration
accessibilityLabel = title
}
// MARK: Selection Logic
private func applySelection(item: Item) {
selectedTitle = item.title
selectedId = item.id
applyTitle(item.title)
onSelect?(item)
}
// MARK: Actions
@objc private func didTap() {
guard let host = parentViewController() else { return }
let controller = DropdownPopoverController(
items: items,
selectedTitle: selectedTitle,
rowHeight: rowHeight,
cellNumberOfLines: cellNumberOfLines
)
controller.onSelect = { [weak self] item in
self?.applySelection(item: item)
}
controller.modalPresentationStyle = .popover
if let popover = controller.popoverPresentationController {
popover.sourceView = self
popover.sourceRect = bounds
popover.permittedArrowDirections = [.up, .down]
popover.delegate = controller
}
let screenHeight = UIScreen.main.bounds.height
let maximumHeight = screenHeight * maxHeightFraction
let totalHeight = CGFloat(items.count) * rowHeight
let finalHeight = min(maximumHeight, totalHeight)
let width = max(bounds.width, minimumPopoverWidth)
controller.preferredContentSize = CGSize(width: width, height: finalHeight)
host.present(controller, animated: true)
}
}
// MARK: - Popover Controller
private final class DropdownPopoverController: UIViewController {
var onSelect: ((DropdownButton.Item) -> Void)?
private let items: [DropdownButton.Item]
private let selectedTitle: String?
private let rowHeight: CGFloat
private let cellNumberOfLines: Int
private let tableView = UITableView(frame: .zero, style: .plain)
init(items: [DropdownButton.Item],
selectedTitle: String?,
rowHeight: CGFloat,
cellNumberOfLines: Int) {
self.items = items
self.selectedTitle = selectedTitle
self.rowHeight = rowHeight
self.cellNumberOfLines = cellNumberOfLines
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
return nil
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
tableView.dataSource = self
tableView.delegate = self
tableView.rowHeight = rowHeight
tableView.alwaysBounceVertical = true
tableView.tableFooterView = UIView()
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}
// MARK: - UIPopoverPresentationControllerDelegate
extension DropdownPopoverController: UIPopoverPresentationControllerDelegate {
func adaptivePresentationStyle(for controller: UIPresentationController,
traitCollection: UITraitCollection) -> UIModalPresentationStyle {
return .none
}
}
// MARK: - UITableViewDataSource
extension DropdownPopoverController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return items.count
}
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let item = items[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
var content = cell.defaultContentConfiguration()
content.text = item.title
content.textProperties.numberOfLines = cellNumberOfLines
content.textProperties.font = UIFont.preferredFont(forTextStyle: .body)
cell.contentConfiguration = content
cell.accessoryType = (item.title == selectedTitle) ? .checkmark : .none
cell.isAccessibilityElement = true
cell.accessibilityTraits = .button
cell.accessibilityLabel = item.title
return cell
}
}
// MARK: - UITableViewDelegate
extension DropdownPopoverController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
onSelect?(items[indexPath.row])
dismiss(animated: true)
}
}
// MARK: - UIView helper (parent view controller lookup)
private extension UIView {
func parentViewController() -> UIViewController? {
var responder: UIResponder? = self
while let current = responder {
if let controller = current as? UIViewController {
return controller
}
responder = current.next
}
return nil
}
}
후기
이번 작업을 통해 느낀 점은 다음과 같다.
- UIMenu는 항목이 적을 때는 간단하게 쓰기 좋다.
- 하지만 항목이 많아질 가능성이 있다면, 다른 방법을 고려하는 게 더 낫다.
- 나는 UIMenu자체를 수정하여 높이를 최적화할 방법을 결국 못 찾았다
- 시트 디자인은 원하지 않았다
- iPhone환경에서 popOver를 구현하는 것이 옳은가에 대한 의문은 아직 있다.
- 그럼에도 당장에 더 좋은 UX를 위해 택했다
결국 이번 케이스는 “일반적인 iPhone UX 패턴”보다는 앵커 기반의 드롭다운 경험을 살리는 게 더 중요했고,
그 목표를 충족시켜 줄 수 있는 해법이 Popover였다고 생각한다.
'Trouble Shooting' 카테고리의 다른 글
| [Swift UIKit] Popover 드롭다운 버튼 화면 전환 버그 수정 과정 (0) | 2025.09.26 |
|---|---|
| [Swift UIKit] Button 터치 피드백이 안보이는 경우 (0) | 2025.05.02 |
| [Swift MVVM] UI와 독립적인 ViewModel, 어디까지 고려해야 할까? (feat. ReactorKit) (0) | 2025.04.17 |
| [Swift UIKit] 앱 전반에서 사용 될 Toast Message 구현기 (0) | 2025.04.16 |
| [Swift UIKit] 점선 스타일 보더 적용하기(Dashed Border Style) (0) | 2025.04.16 |