[swift] UI EventSystem #1 / 3

개요

UIView에 적용할 수 있는 여러가지 인터렉션 모델이 있습니다. 순차적으로 살펴보고 구현방법을 살펴볼건데 IB를 활용하는 방법이 아니라 코드 상으로 처리되는 방법론을 다룹니다(IB에서 하는거야 머 딱히 드래그해서 하면 되니까요 ^^) 총 3회에 걸쳐 설명하게 됩니다.

  1. 기저이벤트와 제스쳐
  2. UIControlEvent와 위임핸들러
  3. 추상이벤트 시스템

기본적인 이벤트의 전파

코코아는 전통적인 이벤트 모델에서 버블링 단계라 알려져 있는 전파를 지원합니다. 즉 화면상 가장 위에 있는 녀석이 처음에 이벤트를 수신하고 그 녀석이 처리하지 않고 밑에 깔려있는 녀석에게 위임하는 식으로 전파됩니다. 여기에 약간 특이한 점은 리스펀더 체인이라고 불리는 직접적인 전파선언입니다. 대부분의 뷰 시스템에서는 자동으로 전파가 일어나는 식인데 코코아에서는 명시적으로 자기가 처리하지 않고 밑에 깔린 넘에게 전파하겠다고 선언해야합니다. 예를 들어 어떤 뷰가 직접적인 처리를 하지 않고 자기 밑에 깔린 뷰에게 이벤트 처리를 명시적으로 떠넘기는 코드는 다음과 같습니다.

class TestView:UIView{
  override public func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
    self.nextResponder()?.touchesBegan(touches, withEvent: event)
  }
}

기저층의 OS레벨 이벤트를 수신하는 인터페이스는 UIResponder클래스로 노출되어있고 이는 다시 UIView와 UIViewController가 상속하게 됩니다.
단 이를 처리하는 방법이 반드시 override 형태로 구상해야만 하기 때문에 서브클래싱을 원하고 있다는 점이 사용 상 불편함을 초래합니다.
하지만 재밌는건 딱히 override를 하지 않으면 해당 View는 터치이벤트 전파에서 배제된다는 것입니다. 화면 상에는 해당 뷰가 존재하지만 그 뷰가 터치이벤트를 수신하는 대상이 되지 않는거죠. 이 경우 알아서 그 뷰는 이벤트를 받지 않고 그 밑에 있는 다른 구현체에게로 이벤트가 갑니다. 간단히 정리해보면

  1. 이벤트를 받아서 처리해야하는 경우만 override하고
  2. 본인이 처리할 내용이 아니라면 밑에 깔린 쪽으로 전파시킨다

라는 전략으로 구현하면 됩니다. 즉 위에 무의미하게 전파시키는 override는 필요없다….라는건데 여기서 개별 뷰 차원이 아니라 더 고차원적인 프레임웍 레벨을 고려하는 패턴을 만들게 되면 다른 얘기가 됩니다. 이벤트전파를 하지 않는 것에서 기본 전파하는 것으로 바꾼다고 치면 다음과 같이 구현할 수 있습니다.

extension UIView{
  override public func touchesBegan(touches:Set<UITouch>, withEvent event:UIEvent?){
    nextResponder()?.touchesBegan(touches, withEvent:event)
  }
}
extension UIViewController{
  override public func touchesBegan(touches:Set<UITouch>, withEvent event:UIEvent?){
    nextResponder()?.touchesBegan(touches, withEvent:event)
  }
}

일단 이런 정책을 실시하면 모든 뷰는 이벤트 전파가 기본 동작이 되고 직접 override를 서브클래싱한 경우는 별도 정책을 쓰는 식으로 변화하게 됩니다.
일단은 이 정도로 두고 나중에 추상이벤트층을 구현할때 다시 활용해보겠습니다.

UIResponder레벨의 이벤트 사용

여기까지의 지식을 이용해 간단히 드래그를 구현해보죠.

class DragView:UIView{
  override public func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) {
    if let loc = touches.first?.locationInView(self.superview!){
      layer.setValue(loc.x, forKeyPath:"position.x")
      layer.setValue(loc.y, forKeyPath:"position.y")
    }
  }
}

class MainController:UIViewController{
  override func viewDidLoad(){
    super.viewDidLoad()
    let box = DragView(frame:CGRectMake(100, 100, 100, 100))
    box.backgroundColor = UIColor.blurColor()
    self.view.addSubview(box)
  }
}

간단한 구현으로 드래그가 가능하게 되었습니다. 헌데 차라리 override안한 UIView를 사용하면 이벤트는 자동으로 MainController가 받게 되므로 별도 구현을 안하고 컨트롤러 레벨에서 구현하는 방법도 있습니다. 이를 이용하면 여러개의 뷰를 드래그하는 코드도 간단히 구현할 수 있습니다.

class MainController:UIViewController{
  override func viewDidLoad(){
    super.viewDidLoad()

    //그냥 UIViewe다!
    let box = UIView(frame:CGRectMake(100, 100, 100, 100))
    box.backgroundColor = UIColor.blurColor()
    self.view.addSubview(box)
  }

  override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) {
    if let view = touches.first?.view{
      if let loc = touches.first?.locationInView(self.view){
        view.layer.setValue(loc.x, forKeyPath:"position.x")
	view.layer.setValue(loc.y, forKeyPath:"position.y")
      }
    }
}

이 예에서는 override하지 않은 평범한 UIView라 이벤트 체이닝에서 배제되므로 마지막에 컨트롤러가 수용하게 됩니다. 컨트롤러는 터치포인트로부터 뷰를 판단하여 이동을 처리합니다(보다 안정적인 작동을 위해서는 touchesBegan에서 대상을 확정짓고 move시키는게 좋습니다만)
위의 패턴은 개별 view에는 아무런 처리를 하지 않고 감싸는 래퍼에서 처리하게 되므로 일종의 이벤트 위임패턴이 됩니다(JS에서 흔한..)

UIGestureRecognizer 레벨의 이벤트

UIView에서는 이러한 override모델보다 좀 더 객체지향화한 추상이벤트를 제공하는데 바로 제스쳐입니다. 제스쳐는 다음의 흐름을 따릅니다.

  1. 제스쳐를 생성하고 설정한다.
  2. 뷰에 제스쳐를 추가한다.
  3. 해당 뷰에 제스쳐가 발생하면 미리 지정된 타겟의 셀렉터가 호출된다.

간단히 더블탭을 하는 예제로 흐름을 익혀보죠.

class MainController:UIViewController{
  override func viewDidLoad(){
    super.viewDidLoad()
    let box = UIView(frame:CGRectMake(100, 100, 100, 100))
    box.backgroundColor = UIColor.blurColor()
    self.view.addSubview(box)

    //1 제스쳐 생성 및 설정 - 이 단계에서 셀렉터가 지정됨
    let doubleTab = UITapGestureRecognizer(target:self, action: #selector(MainController.onDoubleTab))
    doubleTab.numberOfTapsRequired = 2
    
    //2 뷰에 제스쳐를 붙인다.
    box.addGestureRecognizer(doubleTab)
  }

  //제스쳐셀렉터
  func onDoubleTab(sender:UITapGestureRecognizer){
    //3 더블탭하면 해당 뷰를 지워버림
    sender.view?.removeFromSuperview()
  }
}

머 쉽고 간단합니다. 왜냐면 서브클래싱이 필요없기 때문이죠. 하지만 매번 셀렉터를 위해 클래스를 만들기는 짜증납니다. 따라서 셀렉터용 클래스를 하나 만들어서 클로저로 통제하도록 하죠.

class GestureListener{

  //실제 실행할 클로저를 잡아둔다.
  private let run:UITapGestureRecognizer->Void
   
  init(_ run:UITapGestureRecognizer->Void){
    self.run = run
  }

  func onGesture(sender:UITapGestureRecognizer){
    //클로저에게 위임
    run(sender)
  }
}

이제 이 간단한 리스너 클래스로 갈음할 수 있습니다.

class MainController:UIViewController{
  override func viewDidLoad(){
    super.viewDidLoad()
    let box = UIView(frame:CGRectMake(100, 100, 100, 100))
    box.backgroundColor = UIColor.blurColor()
    self.view.addSubview(box)

    //클로저를 이용해 간단히 설정
    let doubleTab = UITapGestureRecognizer(
      target:GestureListener{$0.view?.removeFromSuperview()}, 
      action: #selector(GestureListener.onGesture)
    )
    doubleTab.numberOfTapsRequired = 2
    
    box.addGestureRecognizer(doubleTab)
  }
}

이렇게 타겟 액션을 클로저를 바꿔주는 중간 객체를 이용하면 정적 시점에 리스너를 확정짓는 부담을 벗어나 런타임 클로저로 대체할 수 있는 쿠션이 됩니다.

UIResponder도 상속하지 않고 설정하기

제스쳐를 손쉽게 View에 부착했다 떼어내는 과정은 런타임에 이뤄지므로 편리합니다. 그에 비해 UIResponder는 정적 시점에 서브클래싱을 요구하므로 귀찮습니다. 이는 제스쳐의 경우처럼 쿠션이 되는 객체로는 해결되지 않습니다. 메세지 전송이 아니라 서브클래싱이기 때문에 결국 extension으로 처리해야합니다.
그럼 전체 View에게 영향을 줄 기능이므로 기능요구사항을 좀 자세히 살펴볼 필요가 있죠.

  1. 다수의 클로져를 등록하여 실행할 수 있다.
  2. 전파를 하거나 멈추는걸 클로져에서 결정할 수 있다.
  3. 자유롭게 클로저를 추가, 삭제할 수 있다.

머 이런 정도 아니겠습니까. 통합이벤트 시스템을 전부 구축하면 코드가 길어지므로 이번에는 간단히 touchesBegan만 구현해보도록 하죠.

이벤트 클래스

2번 항목이 처리되려면 각 리스너가 뷰를 인식해야하는데 클로저인 이상 사후적으로 소유자인 뷰를 인식하는건 불가능합니다. 따라서 뷰에 기능성 이벤트 객체를 넘겨주고 이를 통해 처리하는 방법을 생각해봐야합니다. 이벤트 객체에 많은 기능은 생략하고 우선 touchesBegan이 받는 인자와 전파방지를 위한 기능만 넣어서 구성해보죠.

class Event{
  let type:String //이벤트 종류
  let sender:Set<UITouch>
  let event:UIEvent?
  private var isStop = false //전파여부

  init(_ type:String, _ sender:Set<UITouch>, _ event:UIEvent?){
    self.type = type
    self.sender = sender
    self.event = event
  }
  func stopChaining(){ //전파금지
    isStop = true
  }
  func isStopChaining()->Bool{
    return isStop
  }
}

이제 개별 리스너클로저에서는 이벤트 객체를 인자로 받아 정보를 꺼내쓰고 전파를 막기 위해서는 이벤트의 stopChaining을 이용할 수 있습니다.

리스너 클래스

뷰에서 개별 리스너를 추가삭제하려면 해당 리스너를 인식할 수 있어야합니다. 생성자가 클로저를 받아주지만 비교가 가능하도록 hashable을 구상한 클래스가 필요합니다(클로저레벨에서 동등비교를 하면 포인터레벨의 비교로 내려가야하므로 자중하겠습니다)
특정 클래스가 동등성과 식별성을 갖는 방법중 젤 쉬운 것은 NSObject를 상속하는 것입니다. 하지만 상속구조에 막혀 불가능할 수도 있으니 직접 구현하는 예를 작성해보죠.

//동등성과 식별성을 위해 두 개의 프로토콜을 구상한다
class EventListener:Equatable, Hashable{
  //우선 hashable 커버는 손쉽게 ObjectIdentifier로 할 수 있다!
  var hashValue:Int{return ObjectIdentifier(self).hashValue}{
}

//동등성은 반드시 전역공간에서 ==를 구상해야함
func ==(lhs:EventListener, rhs:EventListener)->Bool{
  return lhs === rhs
}

이 정도면 이제 객체간 비교도 가능하고 딕셔너리에도 넣을 수 있는 클래스가 되었습니다. 이게 참..걍 상속구조가 자유로우면 다음의 정도로 간단히 처리하는게 나은 경우가 많습니다.

class EventListener:NSObject{
}

자 클래스의 기본적인 조건을 갖췄으니 내용을 채우죠. 이벤트를 받는 리스너형을 선언하고 이를 통해 내부 클로저를 설정합니다.

class EventListener:Equatable, Hashable{
  var hashValue:Int{return ObjectIdentifier(self).hashValue}{

  //클로저의 형
  typealias listener = Event->Void

  let run:listener
  init(_ run:listener){
    self.run = run
  }
}

리스너 클로저를 담는 그릇은 준비가 끝났으니 이제 이를 배열로 관리하는 시스템만 만들면 됩니다.

UIView확장

UIView를 확장하면 직접 저장할수는 없으니 UIView.hash를 이용해 외부 딕셔너리에 값을 저장하는 스타일로 가야합니다. 이 방법으로 리스너를 관리하게 하고 리스너를 등록하고 삭제해보죠.

//전체 리스너를 담는 그릇
let listeners = NSMutableDictionary()

extension UIView{
  func addTouchBegan(_ listener:EventListener){

    //이 뷰를 위한 저장공간을 만든적이 없다면..배열생성
    if nil == listeners[UIView.hash] {
      listeners[UIView.hash] = NSMutableArray()
    }
    
    //리스너를 추가한다.
    (listeners[UIView.hash] as? NSMutableArray).addObject(listener)
  }

  func removeTouchBegan(_ listener:EventListener){
    if nil != listeners[UIView.hash] {
      (listeners[UIView.hash] as? NSMutableArray).removeObject(listener)
    }
  }
}

이를 호스트코드에서는 다음과 같이 사용할 수 있게 됩니다.

let box = UIView(frame:CGRectMake(100, 100, 100, 100))
box.backgroundColor = UIColor.blurColor()
//리스너생성
let listener = EventListener{print("\($0.sender)")}

//추가
box.addTouchBegan(listener)

//삭제
box.removeTouchBegan(listener)

리스너 관리는 되니까 실제로 작동해야겠죠. override를 구상합니다.

extension UIView{
  override public func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
    if let arr = listeners[UIView.hash] as? NSMutableArray{
      //1 이벤트 객체를 만든다
      let e = Event("touchesBegan", sender, event)

      //2. 클로저를 순회하며 호출한다.
      arr.forEach{$0.run(e)}

      //3. 체이닝금지가 아니라면 체이닝한다.
      if !e.isStopChaining(){
        nextResponder()?.touchesBegan(touches, withEvent: event)
      }
    }
  }
}

별것도 없습니다. 이제 손쉽게 기저레벨의 이벤트도 클로저를 추가삭제할 수 있게 되었습니다.

전체코드

이번 섹션에서 언급된 전체 코드는 다음과 같습니다.

//이벤트
class Event{
  let type:String, sender:Set<UITouch>, event:UIEvent?
  private var isStop = false
  init(_ type:String, _ sender:Set<UITouch>, _ event:UIEvent?){
    self.type = type
    self.sender = sender
    self.event = event
  }
  func stopChaining(){
    isStop = true
  }
  func isStopChaining()->Bool{
    return isStop
  }
}

//리스너
class EventListener:Equatable, Hashable{
  var hashValue:Int{return ObjectIdentifier(self).hashValue}{
  typealias listener = Event->Void
  let run:listener
  init(_ run:listener){
    self.run = run
  }
}

//UIView 확장
let listeners = NSMutableDictionary()
extension UIView{
  func addTouchBegan(_ listener:EventListener){
    if nil == listeners[UIView.hash] {
      listeners[UIView.hash] = NSMutableArray()
    }
    (listeners[UIView.hash] as? NSMutableArray).addObject(listener)
  }

  func removeTouchBegan(_ listener:EventListener){
    if nil != listeners[UIView.hash] {
      (listeners[UIView.hash] as? NSMutableArray).removeObject(listener)
    }
  }
  override public func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
    if let arr = listeners[UIView.hash] as? NSMutableArray{
      let e = Event("touchesBegan", sender, event)
      arr.forEach{$0.run(e)}
      if !e.isStopChaining(){
        nextResponder()?.touchesBegan(touches, withEvent: event)
      }
    }
  }
}

실제 사용은 아래와 같이 하게 되겠죠.

let box = UIView(frame:CGRectMake(100, 100, 100, 100))
box.backgroundColor = UIColor.blurColor()

let listener = EventListener{ event in
  print("\(event.type)") //"touchesBegan
  print("\(event.sender)") //touches..
  print("\(event.event)") //UIEvent..
  event.stopChaining() //전파하지 않음
}

box.addTouchBegan(listener)

이제 서브클래싱없이도 UIResponder레벨의 이벤트를 사용할 수 있게 되었습니다.

결론

이벤트 처리 첫번째 글에서는 기초가되는 UIResponder체이닝과 제스쳐이벤트를 알아봤습니다. 다음글에서는 UIControlEvent레벨을 다루고 UIControlEvent, 제스쳐, UIResponder레벨의 이벤트가 어떻게 경합하는지도 같이 살펴봅니다.

%d 블로거가 이것을 좋아합니다: