이벤트는 루트에서 내려갔다가 다시 올라온다
Esc

    이벤트는 루트에서 내려갔다가 다시 올라온다

    –UnityUIUI Toolkit

    해상도 대응은 씬이 아니라 에셋 한 장에 적힌다까지가 화면을 띄우는 이야기였습니다. 이번은 그 화면이 입력을 받는 쪽입니다.

    uGUI 에서 버튼에 함수를 걸던 방법은 인스펙터였습니다. On Click () 칸에 오브젝트를 끌어다 놓고 목록에서 메서드를 골랐습니다. UI Toolkit 은 화면에 오브젝트를 놓지 않는다의 비교표에서 “Serialized events” 가 uGUI 쪽에만 있던 자리입니다. 그 칸이 없습니다.

    거는 방법은 코드뿐입니다

    세 가지가 있습니다. 하는 일이 조금씩 다릅니다.

    var root = GetComponent<UIDocument>().rootVisualElement;
    // 1. 컨트롤이 들고 있는 이벤트
    root.Q<Button>("ok").clicked += OnOk;
    // 2. 값이 있는 컨트롤
    root.Q<TextField>("name").RegisterValueChangedCallback(evt => Debug.Log(evt.newValue));
    // 3. 아무 요소에나 거는 일반적인 방법
    root.Q<VisualElement>("card").RegisterCallback<PointerDownEvent>(OnDown);

    clicked 는 Button 이 직접 들고 있는 것이고, RegisterValueChangedCallback 은 값을 가진 컨트롤용이고, RegisterCallback<T> 이 밑바탕입니다. 뗄 때는 UnregisterCallback 입니다.

    버튼과 함수를 잇는 이름이 어디에 있는가 uGUI Popup.prefab m_MethodName: "OnOk" PopupView.cs void OnOk() { } 이름을 바꾸면 조용히 끊어집니다 UI Toolkit Popup.uxml <Button name="ok" /> PopupView.cs Q<Button>("ok").clicked += OnOk; 문자열이 코드 쪽에 있습니다
    문자열이 사라진 게 아니라 자리를 옮긴 것입니다. 찾기가 쉬워졌습니다.

    잃는 것과 얻는 것이 분명합니다.

    잃는 것. 기획자나 디자이너가 인스펙터에서 연결을 바꿀 수 없습니다. 화면 하나를 붙이려면 코드가 반드시 하나 있어야 합니다. uGUI 에서 프로그래머 없이 버튼 하나를 임시로 연결해 두던 방식은 여기서 안 됩니다.

    얻는 것. 씬이나 프리팹 안에 메서드 이름이 문자열로 안 박힙니다. uGUI 에서 메서드 이름을 바꾸면 인스펙터 연결이 조용히 끊어졌고, 그게 컴파일 에러로도 안 나왔습니다. 그 사고가 없습니다.

    다만 문자열이 완전히 사라진 것은 아닙니다. Q<Button>("ok") 의 "ok" 가 그 자리를 이어받습니다. 다른 점은 그 문자열이 코드 쪽에 있다는 것입니다. 찾기도 쉽고 상수로 뽑아 두기도 쉽습니다.

    이벤트는 내려갔다가 올라옵니다

    여기서부터가 uGUI 와 다른 부분입니다. 버튼을 눌렀을 때 이벤트가 버튼에서 시작하지 않습니다.

    문서 표현으로 “The path starts at the root of the visual element tree and descends towards the target” 이고, 그다음 “The event then ascends the tree towards the root” 입니다. 루트에서 눌린 요소까지 내려갔다가, 다시 루트까지 올라옵니다.

    그래서 길 위의 요소는 이벤트를 두 번 받습니다. “Every element in the propagation path, including the target, receives the event twice: once during the trickle-down phase and once during the bubble-up phase.”

    등록할 때 어느 길에서 받을지를 고릅니다. 기본은 올라오는 길입니다. 내려가는 길에서 받으려면 인자를 하나 더 줍니다.

    row.RegisterCallback<PointerDownEvent>(OnDown, TrickleDown.TrickleDown);

    문서 표현으로 “if you want a parent element to react before its child, register your callback with the TrickleDown.TrickleDown option” 입니다.

    막는 방법도 있습니다. 콜백 안에서 evt.StopPropagation() 을 부르면 그 위로 안 올라갑니다. 모달 팝업이 뒤 화면의 클릭을 먹어 버리게 만드는 자리가 여기입니다. 같은 요소에 걸린 나머지 콜백까지 끊으려면 evt.StopImmediatePropagation() 입니다.

    모든 이벤트가 두 길을 다 도는 것은 아닙니다. “Most event types are sent to all elements along the propagation path. Some event types skip the bubble-up phase, and some event types are sent to the event target only.” 아래 표의 마지막 칸이 그 얘기입니다.

    자주 쓰는 것만 추리면

    이벤트 언제 내려감 올라옴
    PointerDownEvent / PointerUpEvent 포인터를 누르고 뗐을 때 예 예
    PointerMoveEvent 포인터가 움직일 때 예 예
    PointerEnterEvent / PointerLeaveEvent 요소 안에 들어오고 나갈 때 예 아니오
    ClickEvent 같은 요소에서 눌렀다 뗐을 때 예 예
    KeyDownEvent / KeyUpEvent 키를 눌렀다 뗐을 때 예 예
    ChangeEvent<T> 값이 바뀌었을 때 예 예

    ClickEvent 는 “occurs when the user clicks the left mouse button (or the first button on a pointing device) over a VisualElement” 이고, 누르기와 떼기가 같은 요소 에서 일어나야 합니다. 그 사이에 포인터가 움직여도 상관없습니다.

    PointerEnterEvent 와 PointerLeaveEvent 가 안 올라온다는 점은 기억해 둘 값어치가 있습니다. 부모에 걸어 놓고 자식 위에 커서가 올라온 것을 받으려 하면 안 옵니다.

    마우스 이벤트도 따로 있지만 새로 쓸 일은 거의 없습니다. 문서 표현으로 “Pointer events always precede mouse events in UI Toolkit” 입니다. 터치와 펜까지 같이 받으려면 포인터 쪽입니다.

    이름만 알아 두면 되는 것도 둘 있습니다. GeometryChangedEvent 는 그 요소의 크기나 자리가 실제로 바뀌었을 때 옵니다. 레이아웃이 끝난 뒤의 실제 값이 필요할 때 여기서 읽습니다. AttachToPanelEvent 와 DetachFromPanelEvent 는 요소가 판에 붙고 떨어질 때입니다. 콜백을 걸고 떼기 좋은 자리입니다.

    포인터를 안 받게 하려면 pickingMode

    눌리는 것은 전부 같은 상태 다섯 개를 쓴다에서 uGUI 의 Raycast Target 을 봤습니다. 그 자리에 있는 것이 pickingMode 입니다.

    고르는 순서는 그리는 순서의 반대입니다 앞 뒤 덮개 VisualElement picking-mode="Ignore" 통과합니다 투명한 배경 판 picking-mode="Position" 여기서 멈춥니다 누르고 싶었던 Button 안 눌립니다 투명해도 위에 있으면 클릭을 먼저 먹습니다. 안 보인다고 안 막는 것이 아닙니다
    덮개를 Ignore 로 두면 통과하고, Position 으로 두면 뒤를 막는 용도가 됩니다.
    overlay.pickingMode = PickingMode.Ignore;

    uxml 에서는 picking-mode="Ignore" 로 적습니다. 유니티 소스의 설명은 “Prevents this element from being the target of pointer events” 입니다. 기본값은 Position 입니다.

    고르는 순서도 소스에 적혀 있습니다. “the picking process works in reverse order to rendering: it starts with the front-most elements and proceeds step by step toward background elements.”

    그리는 순서의 반대로 찾습니다. 그래서 12편에서 본 함정이 여기서도 그대로입니다. 투명해도 위에 덮여 있으면 클릭을 먼저 먹습니다. 배경으로 깔아 둔 큰 VisualElement 때문에 밑의 버튼이 안 눌린다면 거기에 pickingMode 를 꺼 둡니다.

    반대로 뒤 화면을 막는 것이 목적이면 덮개를 Position 으로 두면 됩니다. 별도 설정 없이 그 자체로 막힙니다.

    키보드는 포커스를 가진 요소만 받습니다

    키 이벤트는 아무 요소나 받는 게 아닙니다. focusable 이 켜져 있고 실제로 포커스를 가진 요소가 받습니다. 그 요소에서 시작해서 위아래로 퍼집니다.

    속성 문서 설명
    focusable “True if the element can be focused”
    tabIndex “An integer used to sort focusables in the focus ring. Must be greater than or equal to zero”
    delegatesFocus “Whether the element should delegate the focus to its children”

    포커스 이벤트는 넷인데 둘씩 짝입니다. 바뀌기 전과 바뀐 뒤 로 갈립니다.

    이벤트 시점 길
    FocusOutEvent “Sent before an element loses focus” 내려갔다 올라옴
    FocusInEvent “Sent before an element gains focus” 내려갔다 올라옴
    BlurEvent “Sent after an element has lost focus” 내려가기만
    FocusEvent “Sent after an element has gained focus” 내려가기만

    입력값을 검사해서 포커스를 못 넘기게 막을 일이면 FocusOutEvent 쪽이고, 포커스가 넘어간 뒤 테두리를 바꾸는 정도면 FocusEvent 쪽입니다.

    패드나 키보드로 조작하는 화면이면 tabIndex 를 정해 두는 편이 낫습니다. uGUI 의 Navigation 설정이 하던 자리인데, 여기서는 숫자 하나입니다.

    정리

    • 인스펙터의 On Click () 칸이 없습니다. clicked +=, RegisterValueChangedCallback, RegisterCallback<T> 셋 중 하나로 코드에서 겁니다
    • 대신 씬이나 프리팹에 메서드 이름이 안 박혀서, 이름을 바꿔도 연결이 조용히 끊기지 않습니다
    • 이벤트는 루트에서 눌린 요소까지 내려갔다가 다시 루트로 올라옵니다. 길 위의 요소는 두 번 받습니다
    • 기본 등록은 올라오는 길입니다. 부모가 먼저 받게 하려면 TrickleDown.TrickleDown 을 줍니다
    • StopPropagation() 으로 그 위를 끊고, StopImmediatePropagation() 으로 같은 요소의 나머지 콜백까지 끊습니다
    • PointerEnterEvent 와 PointerLeaveEvent 는 안 올라옵니다. 부모에 걸어 두면 안 옵니다
    • 클릭을 안 먹으려면 pickingMode = PickingMode.Ignore. 고르는 순서는 그리는 순서의 반대 입니다
    • 키 이벤트는 focusable 하고 포커스를 가진 요소만 받습니다. 포커스 이벤트는 바뀌기 전 두 개와 바뀐 뒤 두 개입니다