김경록의 앱 개발 여정

[Swift UIKit] 유저 사용성을 고려한 UI의 재설계 과정 (드롭 다운 버튼) 본문

Trouble Shooting

[Swift UIKit] 유저 사용성을 고려한 UI의 재설계 과정 (드롭 다운 버튼)

Kim Roks 2025. 9. 19. 19:23

개요

신규 프로젝트에서 사유 선택을 위한 드롭다운 버튼이 필요했다.
드롭다운 버튼이라는 게 사실 앱보다는 웹 환경에서 더 자주 쓰이는 UI 같지만,
이번 프로젝트의 디자인은 웹 UI를 앱에 맞게 리디자인하는 과정이었기 때문에
가능한 한 동기화를 시키려고 했다.

처음 해보는 작업이라 구글링을 해봤는데, 대부분 서드파티 라이브러리를 이용해서 해결하고 있었다.
하지만 이번 프로젝트는 SnapKit 외에는 서드파티를 쓰지 않는 것을 희망해서 배제하기로 했다

우선 가장 간단해 보이는 방법을 찾았고, iOS 기본 컴포넌트인 UIMenu로 구현을 시작했다.


UIMenu가 뭐지?

사진첩에서 흔히 볼 수 있는 UI

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였다고 생각한다.