[iOS 6주차] 문제 해결: View 안에서 present하기 - UIResponder.next

DoyleHWorks·2024년 11월 28일
0

문제

배경: 팀 프로젝트 Chiosk

각자 구현한 UI의 병합과 상호작용을 논의할 때, 데이터를 처리하는 별도의 구조가 필요함이 상기되었다. SA 때는 화면에 보이는 UI만을 기준으로 기능을 분리했지만, 뒤늦게 MVC 아키텍처를 도입하여 각 View 요소를 분리하였다. 그런데 View와 Model, 그리고 Controller를 분리하면서 버튼액션(@objc func 등)을 어디에 구현하는지 형태가 갈라지게 되었다.
예를 들어, 상단 메뉴 바에서 카테고리를 선택했을 때 다른 메뉴(뷰)를 보여주는 기능, 그리고 메뉴를 추가했을 때 장바구니에 담기는 기능은 ViewController에 구현했지만, 장바구니를 비우거나 결제하는 버튼액션은 OrderSummaryView에 구현하였다. 결과적으로 비즈니스 로직은 카테고리 선택 기능을 제외하고는 모두 싱글톤 패턴이 적용된 OrderManager라는 Model에서 처리하게 되지만, 버튼을 통한 표면적인 상호작용은 서로 다른 곳에 구현이 된 셈이다.
그런 상황에서 '취소하기' 버튼에 Alert 창을 띄우는 기능을 추가하는 형태를 고민하게 되었는데, 이를 기회 삼아 여러 방법을 시도해보고 결론적으로는 버튼액션 구현의 장소가 통일될 수 있도록 하였다.

문제 인식

누르면 바로 반응하는 '취소하기' 버튼

위와 같이 장바구니를 비워주는 취소하기 버튼을 구현했는데, 여러 항목을 고민해서 추가했다가 실수로 누르면 곤란하겠다는 생각이 들었다. 그래서 UIAlertController를 이용해 사용자의 의사를 확인하는 Alert를 구현하기로 했다.

그런데 기존의 코드 구조를 참조하며 @objc func를 구현하려 했더니, MainViewControllerOrderSummaryView 양측에 버튼액션이 구현되어 있었다.

문제 접근

@objc private func handleCancelOrder() {
	// 주문 내역이 비어있는지 확인
	guard !OrderManager.shared.orders.isEmpty else {
    print("취소할 주문 내역이 없습니다.")
    return
	}
    
    // UIAlertController로 사용자 확인
    let alert = UIAlertController(
        title: "주문 취소",
        message: "정말로 주문을 취소하시겠습니까?",
        preferredStyle: .alert
    )

    // "확인" 버튼 액션
    let confirmAction = UIAlertAction(title: "확인", style: .destructive) { _ in
        // 주문 취소 로직 수행
        if OrderManager.shared.orders.isEmpty {
            print("취소할 주문 내역이 없습니다.")
        } else {
            print("주문이 취소되었습니다.")
            OrderManager.shared.resetOrders()
            NotificationCenter.default.post(name: .orderUpdated, object: nil)
        }
    }

    // "취소" 버튼 액션
    let cancelAction = UIAlertAction(title: "취소", style: .cancel, handler: nil)

    // 액션 추가
    alert.addAction(confirmAction)
    alert.addAction(cancelAction)

    // Alert 뷰를 표시
    self.present(alert, animated: true, completion: nil)
}

위가 Alert 뷰를 띄우기 위해 구현한 코드이다. 구현부의 끝에서 Alert 뷰를 표시하는 selfpresent 메서드를 가지고 있어야 한다. UIView 클래스를 상속한 OrderSummaryView에는 이러한 메서드가 없다. 반대로 UIViewController 클래스는 present 메서드를 가지고 있다.

문제 해결

옳거니, 그럼 위 코드는 UIViewController 클래스를 상속한 MainViewController에 구현하면 되겠다.

문제 해결

같은 곳에서 버튼액션을 관리하기 위해 View에 있던 나머지 버튼액션도 MainViewController에 옮겨 구현해주었다.

그런데 만약 해당 View에 속한 버튼의 액션은 모두 그 View 안에 구현하고 싶다면 어떻게 해야 할까?
방법이 없는지 찾아보았다.

추가 리서치

추가 코드 (기존의 코드에 추가하면 View 안에 구현 가능)

// 뷰 컨트롤러에서 present
    if let viewController = findViewController() {
        viewController.present(alert, animated: true, completion: nil)
    }

여기서 findViewController 함수는 extension UIView를 통해 구현 가능하며, 그 View에서 가장 가까운 뷰컨트롤러를 찾아 반환한다.

extension UIView {
    func findViewController() -> UIViewController? {
        var responder: UIResponder? = self
        while let nextResponder = responder?.next {
            if let viewController = nextResponder as? UIViewController {
                return viewController
            }
            responder = nextResponder
        }
        return nil
    }
}

이것이 가능한 이유는 UIViewUIViewController가 모두 UIResponder라는 클래스를 상속 받았고, 나아가 UIResponder 클래스는 next라는 연산 프로퍼티를 갖고 있기 때문이다. next 속성은 상위 뷰(superview) 또는 뷰 컨트롤러를 가리킨다.

이를 토대로 findViewController 함수를 차례대로 설명하면 이렇다:

extension UIView {
	// 반환 타입은 UIViewController?로, UIView가 속한 뷰 컨트롤러를 반환하거나 없으면 nil을 반환한다.
    func findViewController() -> UIViewController? {
		// self는 메서드를 호출한 뷰(UIView)를 의미함
        var responder: UIResponder? = self
        // 상위 뷰 또는 뷰 컨트롤러를 다음 응답자로 반환함
        while let nextResponder = responder?.next {
        	// 현재 응답자가 UIViewController인지 확인 (UIViewController에 present 메서드가 있기 때문)
            if let viewController = nextResponder as? UIViewController {
                return viewController // UIViewController가 맞다면 해당 컨트롤러 반환
            }
            responder = nextResponder // 다음 (상위) 응답자로 이동 (루프)
        }
        return nil // 루프가 끝나도 UIViewController가 없으면 nil 반환
    }
}

인사이트

  • UIView는 뷰 컨트롤러와 연결되어 있지만, 이를 직접 참조하는 속성을 따로 제공하지는 않는다.
  • 위와 같은 메서드는 뷰와 컨트롤러의 관계를 동적으로 확인할 수 있게 해준다.
  • 뷰 내부에서 컨트롤러의 기능을 사용할 필요가 있는 경우(예: present 호출), 매우 유용하다.
  • 캡슐화: 뷰가 컨트롤러의 역할에 의존하지 않도록 설계해야 한다. 이 메서드는 편리하지만 MVC의 역할 분리 원칙을 약간 흐릴 수 있다.
  • 테스트: 특정 뷰가 컨트롤러 없이 독립적으로 생성된 경우(UIView만 사용되는 경우), 이 메서드는 항상 nil을 반환한다.



What I've learned:

UIResponder.next

next 속성은 UIKit의 UIResponder 클래스에서 제공하는 속성으로, 현재 객체의 "다음 응답자"를 반환한다. UIKit의 뷰와 뷰 컨트롤러는 모두 UIResponder를 상속하므로, 이벤트 처리 체계에서 다음으로 이벤트를 전달할 객체를 나타낸다.


next의 역할

  • 이벤트 전달 체계에서 다음 객체를 가리킴
    • UIResponder는 터치 이벤트나 모션 이벤트와 같은 사용자 입력을 처리함
    • next는 이벤트가 전달될 다음 객체를 나타냄
    • 이벤트가 처리되지 않으면 next로 이벤트가 전파됨

next 속성의 반환값

next는 현재 객체의 다음 응답자 객체를 반환하며, 반환값은 객체의 종류에 따라 다르다.

  1. UIView

    • UIView의 경우, next소속된 상위 뷰(superview) 또는 뷰 컨트롤러를 반환한다.
    • 예:
      • 버튼의 next는 버튼을 포함하는 뷰.
      • 최상위 뷰의 next는 해당 뷰를 소유하는 뷰 컨트롤러.
  2. UIViewController

    • UIViewControllernext부모 뷰 컨트롤러 또는 해당 컨트롤러를 포함하는 뷰 계층의 상위 컨트롤러를 반환한다.
    • 예:
      • 내비게이션 컨트롤러에 속한 뷰 컨트롤러의 next는 내비게이션 컨트롤러.
  3. 최상위 객체

    • 최상위 UIResponder(예: UIApplication)의 경우, nextnil을 반환한다.

UIKit에서의 객체 관계

next 속성의 동작은 객체의 계층 구조에 따라 결정된다. 아래는 UIViewUIViewController에서의 관계를 나타낸다.

  1. UIView 계층 구조

    • 자식 뷰 → 부모 뷰(superview) → 부모의 상위 뷰 → ...
    • 최상위 뷰 → 뷰 컨트롤러
  2. UIViewController 계층 구조

    • 자식 뷰 컨트롤러 → 부모 뷰 컨트롤러 → 컨테이너 뷰 컨트롤러(예: UINavigationController)

예시 코드

뷰의 next를 확인하기

let button = UIButton()
if let nextResponder = button.next {
    print("Button's next responder is: \(nextResponder)")
}
  • 버튼이 상위 뷰에 속해 있다면, next는 상위 뷰를 반환한다.
  • 버튼이 최상위 뷰의 자식이라면, next는 해당 뷰 컨트롤러를 반환한다.

뷰 컨트롤러의 next를 확인하기

let viewController = UIViewController()
if let nextResponder = viewController.next {
    print("ViewController's next responder is: \(nextResponder)")
}
  • 뷰 컨트롤러가 내비게이션 컨트롤러에 속해 있다면, next는 내비게이션 컨트롤러를 반환한다.
  • 독립적인 뷰 컨트롤러라면 nextnil이다.

주의사항

  • next는 런타임에 동적으로 결정되며, 객체 계층에 따라 반환값이 달라진다.
  • 모든 객체가 항상 next를 가지고 있는 것은 아니며, 최상위 객체는 nil을 반환한다.
profile
Reciprocity lies in knowing enough

0개의 댓글