[iOS] Pull down / Pop up button

Charlie·2024년 3월 14일
post-thumbnail

이번 포스팅에서는 프로젝트에서 옵션을 설정할 때 사용했던 컴포넌트인 Pop up button과 Pull down button에 대해서 알아보겠습니다.

기존에는 여러 옵션 가운데 하나의 옵션만 선택 가능한(mutual-exclusive) 상황을 CollectionView를 통해 선택할 수 있도록 하였습니다. 모든 옵션들을 보여주고, 옵션에 해당하는 Cell을 선택하는 형식으로 처리했습니다.

그러나 UX를 개선하고자 몇 가지 수정을 하고 UI를 업데이트하는 과정에서 CollectionView를 사용하는 방법보다 더 나은 방법이 있는지 고민하는 과정에서 Pull-down, Pop-up button을 알게 되었습니다.






Pop-up buttons

A pop-up button displays a menu of mutually exclusive options.

HIG에서는 이를 위와 같이 정의하고 있으며 다음과 같이 사용하라고 말합니다.

  • 한번에 하나의 옵션을 선택하는 상황에서 사용하기
  • 사용자 입장에서 기본 옵션을 고려하기
  • 팝업 버튼을 열지 않아도 어떤 옵션들이 들어있을 지 예상 가능하게 하기
  • 모든 옵션을 보여주기 위한 공간이 부족하거나, 한 번에 모든 옵션을 보여줄 필요가 없을 때 사용하기





Pull-down buttons

A pull-down button displays a menu of items or actions that directly relate to the button's purpose.

반면 Pull-down button에 대해서는 HIG에서 위와 같이 정의하고 있습니다.

  • 버튼 선택지를 너무 적게(최소 3개) 또는 너무 많지 않게 관리하기
  • 일반적으로 메뉴의 아이템만으로 사용자들은 문맥을 알 수 있으니 필요한 경우에만 메뉴 타이틀을 추가하기
  • 메뉴가 destructive할 때 주의하기
  • SF Symbol을 이용한 아이콘 추가하기
  • 현재 화면에서 크게 중요하지 않은 메뉴라면 More pull-down button 고려하기 (ellipsis.circle 사용한 버튼)





구현

showMenuAsPrimaryAction

iOS 14부터 UIButton에 menu를 설정할 수 있습니다.

그리고 UIControl의 프로퍼티인 showMenuAsPrimaryAction을 통해 버튼을 클릭했을 때 설정한 메뉴를 보여줄 수 있습니다.

let button = UIButton()
let menu = UIMenu()
button.menu = menu
button.showMenuAsPrimaryAction = true

만약 showMenuAsPrimaryAction의 값이 false라면 버튼을 꾹 누르고 있으면 메뉴가 나타납니다.




changesSelectionAsPrimaryAction

해당 속성은 iOS 15부터 사용할 수 있는 UIButton의 프로퍼티로, 버튼이 Selection을 추적하여 변화할 지를 설정합니다.

let button = UIButton()
button.changesSelectionAsPrimaryAction = true

Pop up button과 Pull down button은 위 두 프로퍼티를 설정하여 구현할 수 있습니다.





Pop up button

Pop up button의 특성을 생각해보면 다음과 같습니다.

  • 메뉴가 있음 : button.menu != nil
  • 버튼 클릭 시 메뉴가 보임 : button.showMenuAsPrimaryAction = true
  • 메뉴 선택 시 버튼이 변화함 : button.changeSelectionAsPrimaryAction = true



Pull down button

반면 Pull down button의 특성은 다음과 같습니다.

  • 메뉴가 있음 : button.menu != nil
  • 버튼 클릭 시 메뉴가 보임 : button.showMenuAsPrimaryAction = true
  • 메뉴 선택 시 버튼이 변하지 않음 : button.changeSelectionAsPrimaryAction = false



정리하자면 다음과 같습니다.

private let popUpButton: UIButton = {
    let button = UIButton()
    button.menu = /* Menu */
    button.showsMenuAsPrimaryAction = true
    button.changesSelectionAsPrimaryAction = true
    return button
}()

private let pullDownButton: UIButton = {
    let button = UIButton()
    button.menu = /* Menu */
    button.showsMenuAsPrimaryAction = true
    button.changesSelectionAsPrimaryAction = false
    return button
}()





Pop up button에서 Action 취소하기

Pop up button을 활용할 때, 한 옵션 클릭 시 실행되는 로직에 따라 성공할 수도 있고 실패할 수도 있습니다.

예를 들어, 이번 프로젝트에서는 메뉴의 옵션 가운데 사용자와 가까운 순으로 정렬하는 옵션이 있었습니다.

이 때, 해당 메뉴 클릭이 처음이라면 사용자의 위치 권한 동의에 따라 동의를 받는 프롬프트가 나타나고, 위치 권한에 동의하지 않았다면 설정으로 이동하도록 하였습니다.

따라서 '가까운 순' 메뉴를 클릭하였을 때 우선 권한을 먼저 검사한 후 비동의 상태라면 메뉴의 선택을 이전의 옵션으로 돌려줘야 합니다. (이미 메뉴 선택을 하는 순간 버튼은 해당 옵션을 선택한 것임)

위 상황을 해결하기 위해 UIActionstate 프로퍼티를 이용할 수 있습니다.

final class PopUpButton: UIButton {
	let selectedFilterRelay = BehaviorSubject<MyOption>(value: /* initial option */)
	private let options: [MyOption] = [/* options */]
    
	init() {
    	// ...
        
        let menuChildren = self.options.map { filterOption in
                return UIAction(title: filterOption.title) { action in
                    if filterOption == /* distance option */, !self.checkLocationAuthorization() {
                        self.selectMenuOption(self.selectedFilterRelay.value)
                        return
                    }
                    
                    self.selectedFilterRelay.accept(filterOption)
                }
            }
            

        self.menu = UIMenu(children: menuChildren)
        self.preferredMenuElementOrder = .fixed
        self.showsMenuAsPrimaryAction = true
        self.changesSelectionAsPrimaryAction = true
    }
    
    private func checkLocationAuthorization() -> Bool {
        switch LocationManager.instance.status {
        case .authorized:
            return true
        case .denied:
            URLOpener.openURL(urlString: UIApplication.openSettingsURLString)
            return false
        case .notDetermined:
            LocationManager.instance.requestPermission()
            
            // 💡권한 프롬프트에서 위치 권한에 동의한다면 실행할 작업
            LocationManager.instance.scheduledTask = { [unowned self] in
                self.selectMenuOption(.distance)
                self.selectedFilterRelay.accept(.distance)
            }
            return false
        }
    }
        
    private func selectMenuOption(_ option: StoreSortOption) {
        guard let previousOption = self.menu?.children.first(where: { $0.title == option.title }) as? UIAction else { return }
        previousOption.state = .on
    }
}
import CoreLocation
 
public class LocationManager: NSObject, AuthorizationRequired {
    public static let instance = LocationManager()
    
    private lazy var manager: CLLocationManager = {
        let manager = CLLocationManager()
        manager.desiredAccuracy = kCLLocationAccuracyBest
        manager.delegate = self
        return manager
    }()
    
    public private(set) var status: AuthorizationStatus = .notDetermined
	// ...
    
    /// A task which will be executed when the authorization status changed from notDetermined into authorized.
    /// This task will be gotten across from FilteringButton class and do the follwing things
    /// - Select distance option from UIMenu
    /// - FilterOption relay will accept distance option
    public var scheduledTask: (() -> Void)?
    
    private override init() {}
    
    public func requestPermission() {
        guard self.status != .authorized else { return }
        
        self.manager.requestWhenInUseAuthorization()
    }
}

// MARK: - CLLocationManagerDelegate
extension LocationManager: CLLocationManagerDelegate {
    public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        switch manager.authorizationStatus {
        case .notDetermined, .restricted:
            self.status = .notDetermined
            return
        case .denied:
            self.status = .denied
        case .authorizedAlways, .authorizedWhenInUse:
            self.status = .authorized
            self.manager.startUpdatingLocation()
            
            if let task = self.scheduledTask {
                task()
            }
        @unknown default:
            break
        }
        
        self.scheduledTask = nil
    }
    
    // ...
}





Reference

Apple HIG - Pop up buttons
Apple HIG - Pull down buttons
[iOS] Pull Down Button 과 Pop Up Button

profile
Hello

0개의 댓글