아티클

접근성있는 Windows 데스크톱 앱 만들기 (Windows UIA를 중심으로) 4: Win32 Fragment Provider로 복잡한 컨트롤 구현하기

엔비전스 접근성 2026-02-06 17:16:45

들어가며

안녕하세요. 엔비전스 입니다.

3부에서 우리는 Win32에서 IRawElementProviderSimple과 IInvokeProvider를 구현하여 커스텀 버튼에 접근성을 제공하는 방법을 살펴보았습니다. 하나의 HWND가 하나의 UIA 요소에 대응하는 단순한 구조였습니다.

하지만 실제 애플리케이션에서는 더 복잡한 컨트롤이 필요할 수 있습니다. 2부에서 만든 별점 위젯을 생각하시면 됩니다 하나의 컨트롤 안에 5개의 별이 있고, 각 별을 클릭하거나 키보드로 값을 조정할 수 있습니다. 이런 컨트롤은 하나의 HWND 안에 여러 개의 논리적 요소가 존재합니다.

따라서 이번 4부에서는 다음과 같은 내용을 다룹니다.

  • 첫째, Fragment Provider의 개념과 단일 요소 Provider와의 차이를 이해합니다.
  • 둘째, IRawElementProviderFragment와 IRawElementProviderFragmentRoot 인터페이스를 구현합니다.
  • 셋째, IRangeValueProvider를 구현하여 별점 위젯의 값 조정 기능을 노출합니다.
  • 넷째, UiaRaiseAutomationPropertyChangedEvent를 사용하여 2부에서 강조한 양방향 동기화를 Win32에서 구현합니다.

Fragment Provider 이해

먼저 3부에서 구현한 커스텀 버튼과 이번에 구현할 별점 위젯의 근본적인 차이를 이해하는 것이 중요합니다.

단일 요소 Provider vs Fragment Provider

3부의 커스텀 버튼은 단순한 구조였습니다. 하나의 HWND가 하나의 UIA 요소를 나타냈고, IRawElementProviderSimple만 구현하면 충분했습니다. UIA 트리에서 이 버튼은 독립적인 하나의 노드였습니다.

별점 위젯은 다릅니다. 하나의 HWND 안에 논리적으로 구분되는 여러 요소가 있습니다. 컨테이너 역할을 하는 루트 요소가 있고, 그 안에 5개의 별 요소가 자식으로 존재합니다. 이렇게 하나의 창 안에서 여러 UIA 요소가 트리 구조를 이루는 것을 Fragment라고 합니다.

Fragment를 구현하려면 IRawElementProviderSimple에 더해 IRawElementProviderFragment 인터페이스를 구현해야 합니다. Fragment의 루트 요소는 추가로 IRawElementProviderFragmentRoot도 구현해야 합니다.

Fragment 관련 인터페이스 개요

Fragment 구현에 필요한 인터페이스들을 정리하면 다음과 같습니다.

인터페이스 역할 구현 대상
IRawElementProviderSimple 기본 속성과 Control Pattern 제공 모든 요소
IRawElementProviderFragment 트리 탐색(Navigate), 경계 사각형, RuntimeId 제공 Fragment 내 모든 요소
IRawElementProviderFragmentRoot 좌표로 요소 찾기, 포커스된 요소 반환 Fragment의 루트 요소만

별점 위젯의 UIA 트리 구조

그럼 별점 위젯이 UIA 트리에서 어떻게 표현되는지 살펴보겠습니다.

RatingControl (FragmentRoot)
 ControlType: Slider
 Name: "평점"
 Patterns: RangeValue
 [자식 요소들 - 선택적 구현]
     Star1 (ControlType: ListItem)
     Star2 (ControlType: ListItem)
     Star3 (ControlType: ListItem)
     Star4 (ControlType: ListItem)
     Star5 (ControlType: ListItem)

이 글에서는 루트 요소에 RangeValue Pattern을 구현하는 방식에 집중합니다. 개별 별을 자식 요소로 노출하는 방식은 구현 복잡도가 높아 개념만 언급하고 넘어가겠습니다. 실제로 많은 상용 별점 위젯이 슬라이더 방식으로 접근성을 제공합니다.

별점 위젯 설계

본격적인 구현에 앞서 전체 설계를 살펴보겠습니다.

2부 WinUI 3 버전과의 대응 관계

우선, 2부에서 구현한 WinUI 3 별점 위젯과 Win32 버전이 어떻게 대응되는지 비교합니다.

기능 WinUI 3 Win32
컨트롤 클래스 RatingControl : Control HWND + RatingProvider 클래스
Automation Peer RatingControlAutomationPeer RatingProvider (FragmentRoot)
값 범위 패턴 IRangeValueProvider 인터페이스 구현 IRangeValueProvider 인터페이스 구현
Provider 연결 OnCreateAutomationPeer() 오버라이드 WM_GETOBJECT 메시지 처리
값 변경 알림 RaisePropertyChangedEvent() UiaRaiseAutomationPropertyChangedEvent()

핵심 로직은 동일합니다. WinUI 3가 추상화해준 부분을 Win32에서는 직접 구현할 뿐입니다.

클래스 구조

Win32 버전의 별점 위젯은 다음과 같은 구조로 구현합니다.

class RatingProvider : 
    public IRawElementProviderSimple,
    public IRawElementProviderFragment,
    public IRawElementProviderFragmentRoot,
    public IRangeValueProvider
{
    // 하나의 클래스가 모든 인터페이스를 구현
    // Fragment 자식 요소 없이 루트만 구현 (슬라이더 방식)
};

이 글에서는 Fragment의 개념을 보여주기 위해 IRawElementProviderFragment와 IRawElementProviderFragmentRoot를 구현하지만, 실제 자식 요소(개별 별)는 생성하지 않습니다. 루트 요소 하나만으로도 RangeValue Pattern을 통해 기본 접근성을 제공할 수 있습니다.

IRawElementProviderFragment 인터페이스 구현

IRawElementProviderFragment는 UIA 트리 내에서 요소 간 탐색을 가능하게 하는 인터페이스입니다.

인터페이스 구조

interface IRawElementProviderFragment : IUnknown
{
    // 지정된 방향으로 탐색하여 다른 요소 반환
    HRESULT Navigate(
        NavigateDirection direction,
        IRawElementProviderFragment** pRetVal);
    
    // 요소의 고유 식별자 반환
    HRESULT GetRuntimeId(SAFEARRAY** pRetVal);
    
    // 화면 좌표 기준 경계 사각형 반환
    HRESULT get_BoundingRectangle(UiaRect* pRetVal);
    
    // 포함된 다른 Fragment Root 반환 (대부분 NULL)
    HRESULT GetEmbeddedFragmentRoots(SAFEARRAY** pRetVal);
    
    // 이 요소에 포커스 설정
    HRESULT SetFocus();
    
    // 이 Fragment의 루트 요소 반환
    HRESULT get_FragmentRoot(IRawElementProviderFragmentRoot** pRetVal);
};

Navigate 메서드

Navigate는 UIA 클라이언트가 트리 구조를 탐색할 때 호출됩니다. NavigateDirection 열거형은 다섯 가지 방향을 정의합니다.

  • NavigateDirection_Parent: 부모 요소로 이동
  • NavigateDirection_NextSibling: 다음 형제 요소로 이동
  • NavigateDirection_PreviousSibling: 이전 형제 요소로 이동
  • NavigateDirection_FirstChild: 첫 번째 자식 요소로 이동
  • NavigateDirection_LastChild: 마지막 자식 요소로 이동

루트 요소는 부모가 호스트 창이고, 이 글에서는 자식 요소를 구현하지 않으므로 대부분 NULL을 반환합니다.

HRESULT RatingProvider::Navigate(
    NavigateDirection direction,
    IRawElementProviderFragment** pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    *pRetVal = nullptr;
    
    switch (direction)
    {
    case NavigateDirection_Parent:
        // 부모는 호스트 창의 기본 Provider가 처리
        // Fragment 루트는 NULL 반환
        break;
        
    case NavigateDirection_FirstChild:
    case NavigateDirection_LastChild:
        // 이 구현에서는 자식 요소 없음
        // 개별 별을 자식으로 구현한다면 여기서 반환
        break;
        
    case NavigateDirection_NextSibling:
    case NavigateDirection_PreviousSibling:
        // Fragment 루트는 형제가 없음
        break;
    }
    
    return S_OK;
}

자식 요소를 구현한다면 FirstChild에서 첫 번째 별의 Provider를, LastChild에서 다섯 번째 별의 Provider를 반환해야 합니다. 그리고 각 별의 Provider에서는 Parent로 이 루트를, NextSibling과 PreviousSibling으로 인접한 별을 반환해야 합니다.

자식 요소(StarItemProvider)가 있을 경우의 Navigate 구현 예시입니다.

// 루트 Provider의 Navigate - 자식 요소가 있는 경우
HRESULT RatingProvider::Navigate(
    NavigateDirection direction,
    IRawElementProviderFragment** pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    *pRetVal = nullptr;
    
    switch (direction)
    {
    case NavigateDirection_Parent:
        // Fragment 루트는 부모로 NULL 반환
        break;
        
    case NavigateDirection_FirstChild:
        // 첫 번째 별(인덱스 0) 반환
        *pRetVal = new StarItemProvider(this, m_hwnd, 0);
        break;
        
    case NavigateDirection_LastChild:
        // 마지막 별(인덱스 4) 반환
        *pRetVal = new StarItemProvider(this, m_hwnd, 4);
        break;
        
    case NavigateDirection_NextSibling:
    case NavigateDirection_PreviousSibling:
        // Fragment 루트는 형제가 없음
        break;
    }
    
    return S_OK;
}

// 자식 요소(StarItemProvider)의 Navigate
HRESULT StarItemProvider::Navigate(
    NavigateDirection direction,
    IRawElementProviderFragment** pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    *pRetVal = nullptr;
    
    switch (direction)
    {
    case NavigateDirection_Parent:
        // 부모인 루트 Provider 반환
        *pRetVal = m_pRoot;
        m_pRoot->AddRef();
        break;
        
    case NavigateDirection_NextSibling:
        // 다음 별이 있으면 반환 (인덱스 0~3만 다음 형제 있음)
        if (m_starIndex < 4)
        {
            *pRetVal = new StarItemProvider(m_pRoot, m_hwnd, m_starIndex + 1);
        }
        break;
        
    case NavigateDirection_PreviousSibling:
        // 이전 별이 있으면 반환 (인덱스 1~4만 이전 형제 있음)
        if (m_starIndex > 0)
        {
            *pRetVal = new StarItemProvider(m_pRoot, m_hwnd, m_starIndex - 1);
        }
        break;
        
    case NavigateDirection_FirstChild:
    case NavigateDirection_LastChild:
        // 개별 별은 자식이 없음
        break;
    }
    
    return S_OK;
}

이 예시에서 중요한 점은 양방향 탐색이 일관성을 유지해야 한다는 것입니다. A의 NextSibling이 B라면, B의 PreviousSibling은 반드시 A여야 합니다. 또한 반환되는 Provider에 대해 AddRef()를 호출하거나 새 객체를 생성(참조 카운트 1로 시작)해야 합니다.

GetRuntimeId 메서드

RuntimeId는 UIA가 요소를 고유하게 식별하는 데 사용합니다. HWND를 가진 요소는 창 핸들로 식별되므로 NULL을 반환해도 됩니다. HWND가 없는 요소(예: 개별 별)는 고유한 RuntimeId를 제공해야 합니다.

HRESULT RatingProvider::GetRuntimeId(SAFEARRAY** pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    // HWND가 있는 루트 요소는 NULL 반환
    // UIA가 창 핸들로 식별함
    *pRetVal = nullptr;
    return S_OK;
}

만약 HWND가 없는 자식 요소의 RuntimeId를 구현한다면 다음과 같이 합니다.

// 자식 요소용 RuntimeId 구현 예시
HRESULT StarItemProvider::GetRuntimeId(SAFEARRAY** pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    // RuntimeId는 정수 배열로 구성
    // 첫 번째 요소는 UiaAppendRuntimeId, 나머지는 고유 값
    int runtimeId[] = { UiaAppendRuntimeId, m_starIndex };
    
    SAFEARRAY* psa = SafeArrayCreateVector(VT_I4, 0, 2);
    if (psa == nullptr)
        return E_OUTOFMEMORY;
    
    for (LONG i = 0; i < 2; i++)
    {
        SafeArrayPutElement(psa, &i, &runtimeId[i]);
    }
    
    *pRetVal = psa;
    return S_OK;
}

get_BoundingRectangle 메서드

요소의 화면 좌표 기준 경계 사각형을 반환합니다. 스크린 리더가 요소의 위치를 파악하는 데 사용됩니다.

HRESULT RatingProvider::get_BoundingRectangle(UiaRect* pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    // 클라이언트 영역 가져오기
    RECT rc;
    GetClientRect(m_hwnd, &rc);
    
    // 화면 좌표로 변환
    POINT topLeft = { rc.left, rc.top };
    POINT bottomRight = { rc.right, rc.bottom };
    ClientToScreen(m_hwnd, &topLeft);
    ClientToScreen(m_hwnd, &bottomRight);
    
    pRetVal->left = static_cast<double>(topLeft.x);
    pRetVal->top = static_cast<double>(topLeft.y);
    pRetVal->width = static_cast<double>(bottomRight.x - topLeft.x);
    pRetVal->height = static_cast<double>(bottomRight.y - topLeft.y);
    
    return S_OK;
}

기타 메서드

HRESULT RatingProvider::GetEmbeddedFragmentRoots(SAFEARRAY** pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    // 포함된 다른 Fragment Root가 없음
    *pRetVal = nullptr;
    return S_OK;
}

HRESULT RatingProvider::SetFocus()
{
    // 컨트롤에 포커스 설정
    SetFocus(m_hwnd);
    return S_OK;
}

HRESULT RatingProvider::get_FragmentRoot(IRawElementProviderFragmentRoot** pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    // 자기 자신이 Fragment Root이므로 자신을 반환
    *pRetVal = static_cast<IRawElementProviderFragmentRoot*>(this);
    AddRef();
    return S_OK;
}

IRawElementProviderFragmentRoot 인터페이스 구현

IRawElementProviderFragmentRoot는 Fragment의 최상위 요소만 구현하는 인터페이스입니다. 좌표 기반 요소 검색과 포커스 관리 기능을 제공합니다.

인터페이스 구조

interface IRawElementProviderFragmentRoot : IUnknown
{
    // 화면 좌표로 해당 위치의 요소 반환
    HRESULT ElementProviderFromPoint(
        double x, double y,
        IRawElementProviderFragment** pRetVal);
    
    // 현재 포커스된 자식 요소 반환
    HRESULT GetFocus(IRawElementProviderFragment** pRetVal);
};

ElementProviderFromPoint 메서드

주어진 화면 좌표에 있는 요소를 반환합니다. 마우스 포인터 위치의 요소를 찾을 때 사용됩니다.

HRESULT RatingProvider::ElementProviderFromPoint(
    double x, double y,
    IRawElementProviderFragment** pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    // 화면 좌표를 클라이언트 좌표로 변환
    POINT pt = { static_cast<LONG>(x), static_cast<LONG>(y) };
    ScreenToClient(m_hwnd, &pt);
    
    // 클라이언트 영역 내인지 확인
    RECT rc;
    GetClientRect(m_hwnd, &rc);
    
    if (PtInRect(&rc, pt))
    {
        // 자식 요소가 있다면 어떤 별 위인지 계산하여 반환
        // 이 구현에서는 자식이 없으므로 자신을 반환
        *pRetVal = static_cast<IRawElementProviderFragment*>(this);
        AddRef();
    }
    else
    {
        *pRetVal = nullptr;
    }
    
    return S_OK;
}

개별 별을 자식 요소로 구현했다면, 좌표가 어떤 별 위에 있는지 계산하여 해당 별의 Provider를 반환해야 합니다.

GetFocus 메서드

현재 포커스를 가진 자식 요소를 반환합니다.

HRESULT RatingProvider::GetFocus(IRawElementProviderFragment** pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    // 자식 요소가 없으므로 NULL 반환
    // 자식이 있다면 현재 포커스된 별의 Provider 반환
    *pRetVal = nullptr;
    return S_OK;
}

IRangeValueProvider 인터페이스 구현

별점 위젯의 핵심 기능인 값 조정을 위해 IRangeValueProvider를 구현합니다. 2부에서 WinUI 3로 구현한 것과 동일한 인터페이스입니다.

인터페이스 구조

interface IRangeValueProvider : IUnknown
{
    // 현재 값
    HRESULT get_Value(double* pRetVal);
    
    // 읽기 전용 여부
    HRESULT get_IsReadOnly(BOOL* pRetVal);
    
    // 최대값
    HRESULT get_Maximum(double* pRetVal);
    
    // 최소값
    HRESULT get_Minimum(double* pRetVal);
    
    // 작은 변경 단위 (화살표 키)
    HRESULT get_SmallChange(double* pRetVal);
    
    // 큰 변경 단위 (Page Up/Down)
    HRESULT get_LargeChange(double* pRetVal);
    
    // 값 설정
    HRESULT SetValue(double val);
};

속성 메서드 구현

HRESULT RatingProvider::get_Value(double* pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    *pRetVal = m_value;
    return S_OK;
}

HRESULT RatingProvider::get_IsReadOnly(BOOL* pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    // 컨트롤이 활성화되어 있으면 편집 가능
    *pRetVal = !IsWindowEnabled(m_hwnd);
    return S_OK;
}

HRESULT RatingProvider::get_Maximum(double* pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    *pRetVal = 5.0;  // 최대 5점
    return S_OK;
}

HRESULT RatingProvider::get_Minimum(double* pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    *pRetVal = 1.0;  // 최소 1점
    return S_OK;
}

HRESULT RatingProvider::get_SmallChange(double* pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    *pRetVal = 1.0;  // 별 하나 단위
    return S_OK;
}

HRESULT RatingProvider::get_LargeChange(double* pRetVal)
{
    if (pRetVal == nullptr)
        return E_POINTER;
    
    *pRetVal = 1.0;  // 별 하나 단위
    return S_OK;
}

SetValue 메서드

SetValue는 UIA 클라이언트(스크린 리더, 자동화 도구 등)가 프로그래밍 방식으로 값을 변경할 때 호출됩니다.

HRESULT RatingProvider::SetValue(double val)
{
    // 읽기 전용이면 거부
    if (!IsWindowEnabled(m_hwnd))
        return UIA_E_ELEMENTNOTENABLED;
    
    // 범위 검증
    double newValue = val;
    if (newValue < 1.0) newValue = 1.0;
    if (newValue > 5.0) newValue = 5.0;
    
    // 값이 변경되었는지 확인
    if (newValue != m_value)
    {
        double oldValue = m_value;
        m_value = newValue;
        
        // UI 업데이트 (별 다시 그리기)
        InvalidateRect(m_hwnd, nullptr, TRUE);
        
        // UIA 이벤트 발생 - 양방향 동기화의 핵심
        NotifyValueChanged(oldValue, newValue);
    }
    
    return S_OK;
}

양방향 동기화 구현

2부에서 양방향 동기화의 중요성을 강조했습니다. 스크린 리더가 값을 바꿀 때뿐만 아니라 사용자가 마우스나 키보드로 값을 바꿀 때도 스크린 리더에게 알려야 합니다. Win32에서 이를 구현하는 방법을 살펴보겠습니다.

UiaRaiseAutomationPropertyChangedEvent 함수

속성 값이 변경되었을 때 이 함수를 호출하여 UIA 클라이언트에게 알립니다.

HRESULT UiaRaiseAutomationPropertyChangedEvent(
    IRawElementProviderSimple* pProvider,  // 이벤트 발생 요소
    PROPERTYID id,                         // 변경된 속성 ID
    VARIANT oldValue,                      // 이전 값
    VARIANT newValue                       // 새 값
);

값 변경 알림 구현

void RatingProvider::NotifyValueChanged(double oldValue, double newValue)
{
    // 클라이언트가 수신 대기 중인지 확인 (성능 최적화)
    if (!UiaClientsAreListening())
        return;
    
    // 이전 값 VARIANT 설정
    VARIANT varOld;
    VariantInit(&varOld);
    varOld.vt = VT_R8;
    varOld.dblVal = oldValue;
    
    // 새 값 VARIANT 설정
    VARIANT varNew;
    VariantInit(&varNew);
    varNew.vt = VT_R8;
    varNew.dblVal = newValue;
    
    // 속성 변경 이벤트 발생
    UiaRaiseAutomationPropertyChangedEvent(
        static_cast<IRawElementProviderSimple*>(this),
        UIA_RangeValueValuePropertyId,
        varOld,
        varNew);
}

VT_R8은 8바이트 실수(double)를 나타내는 VARIANT 타입입니다. RangeValue의 Value 속성은 double 타입이므로 VT_R8을 사용합니다.

WinUI 3와의 비교

WinUI 3에서는 같은 기능을 더 간결하게 구현했습니다.

// WinUI 3 (2부에서 구현한 코드)
void RatingControl::OnValueChanged(DependencyPropertyChangedEventArgs const& args)
{
    UpdateVisuals();
    
    if (auto peer = FrameworkElementAutomationPeer::FromElement(*this))
    {
        peer.RaisePropertyChangedEvent(
            RangeValuePatternIdentifiers::ValueProperty(),
            args.OldValue(),
            args.NewValue());
    }
}

// Win32 (이번에 구현하는 코드)
void RatingProvider::NotifyValueChanged(double oldValue, double newValue)
{
    if (!UiaClientsAreListening())
        return;
    
    VARIANT varOld, varNew;
    // ... VARIANT 설정 ...
    
    UiaRaiseAutomationPropertyChangedEvent(
        this,
        UIA_RangeValueValuePropertyId,
        varOld,
        varNew);
}

핵심 로직은 동일합니다. WinUI 3가 VARIANT 변환과 타입 안전성을 자동으로 처리해줄 뿐입니다.

모든 입력 경로에서 이벤트 발생

양방향 동기화가 완전하려면 값이 변경되는 모든 경로에서 이벤트가 발생해야 합니다.

  • 마우스 클릭: 사용자가 별을 클릭할 때
  • 키보드 입력: 화살표 키로 값을 조정할 때
  • 프로그래밍 방식: SetValue()가 호출될 때

이 세 경로 모두 NotifyValueChanged()를 호출하도록 구현해야 합니다.

Window Procedure 통합

Provider를 컨트롤의 Window Procedure와 통합합니다.

전체 Window Procedure

LRESULT CALLBACK RatingControlWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    RatingProvider* pProvider = reinterpret_cast<RatingProvider*>(
        GetWindowLongPtr(hwnd, GWLP_USERDATA));
    
    switch (msg)
    {
    case WM_CREATE:
        {
            // Provider 생성
            pProvider = new RatingProvider(hwnd, L"평점");
            SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(pProvider));
        }
        return 0;
        
    case WM_DESTROY:
        {
            if (pProvider != nullptr)
            {
                UiaDisconnectProvider(pProvider);
                pProvider->Release();
                SetWindowLongPtr(hwnd, GWLP_USERDATA, 0);
            }
        }
        return 0;
        
    case WM_GETOBJECT:
        if (static_cast<LPARAM>(lParam) == static_cast<LPARAM>(UiaRootObjectId))
        {
            if (pProvider != nullptr)
            {
                return UiaReturnRawElementProvider(
                    hwnd, wParam, lParam,
                    static_cast<IRawElementProviderSimple*>(pProvider));
            }
        }
        break;
        
    case WM_SETFOCUS:
        {
            // 포커스 받으면 다시 그리기 (포커스 표시)
            InvalidateRect(hwnd, nullptr, TRUE);
            
            // 포커스 이벤트 발생
            if (pProvider != nullptr && UiaClientsAreListening())
            {
                UiaRaiseAutomationEvent(pProvider, UIA_AutomationFocusChangedEventId);
            }
        }
        return 0;
        
    case WM_KILLFOCUS:
        {
            // 포커스 잃으면 다시 그리기
            InvalidateRect(hwnd, nullptr, TRUE);
        }
        return 0;
        
    case WM_LBUTTONDOWN:
        {
            // 포커스 설정
            SetFocus(hwnd);
            
            // 클릭 위치로 값 계산
            int x = LOWORD(lParam);
            RECT rc;
            GetClientRect(hwnd, &rc);
            int starWidth = (rc.right - rc.left) / 5;
            
            double newValue = static_cast<double>((x / starWidth) + 1);
            if (newValue > 5.0) newValue = 5.0;
            
            if (pProvider != nullptr)
            {
                pProvider->SetRatingValue(newValue);
            }
        }
        return 0;
        
    case WM_KEYDOWN:
        {
            if (pProvider == nullptr)
                break;
            
            double currentValue = pProvider->GetRatingValue();
            double newValue = currentValue;
            
            switch (wParam)
            {
            case VK_LEFT:
            case VK_DOWN:
                newValue = currentValue - 1.0;
                break;
                
            case VK_RIGHT:
            case VK_UP:
                newValue = currentValue + 1.0;
                break;
                
            case VK_HOME:
                newValue = 1.0;
                break;
                
            case VK_END:
                newValue = 5.0;
                break;
                
            default:
                return DefWindowProc(hwnd, msg, wParam, lParam);
            }
            
            pProvider->SetRatingValue(newValue);
        }
        return 0;
        
    case WM_PAINT:
        {
            PAINTSTRUCT ps;
            HDC hdc = BeginPaint(hwnd, &ps);
            
            RECT rc;
            GetClientRect(hwnd, &rc);
            
            // 배경
            FillRect(hdc, &rc, reinterpret_cast<HBRUSH>(GetStockObject(WHITE_BRUSH)));
            
            // 별 그리기
            int starWidth = (rc.right - rc.left) / 5;
            double value = pProvider ? pProvider->GetRatingValue() : 3.0;
            
            for (int i = 0; i < 5; i++)
            {
                RECT starRect = {
                    rc.left + i * starWidth,
                    rc.top,
                    rc.left + (i + 1) * starWidth,
                    rc.bottom
                };
                
                // 채워진 별 vs 빈 별
                const wchar_t* starChar = (i < static_cast<int>(value)) ? L"★" : L"☆";
                DrawTextW(hdc, starChar, 1, &starRect,
                    DT_CENTER | DT_VCENTER | DT_SINGLELINE);
            }
            
            // 포커스 표시
            if (GetFocus() == hwnd)
            {
                RECT focusRect = rc;
                InflateRect(&focusRect, -2, -2);
                DrawFocusRect(hdc, &focusRect);
            }
            
            EndPaint(hwnd, &ps);
        }
        return 0;
    }
    
    return DefWindowProc(hwnd, msg, wParam, lParam);
}

창 클래스 등록

ATOM RegisterRatingControlClass(HINSTANCE hInstance)
{
    WNDCLASSEXW wcex = {};
    wcex.cbSize = sizeof(WNDCLASSEXW);
    wcex.style = CS_HREDRAW | CS_VREDRAW;
    wcex.lpfnWndProc = RatingControlWndProc;
    wcex.hInstance = hInstance;
    wcex.hCursor = LoadCursor(nullptr, IDC_ARROW);
    wcex.lpszClassName = L"RatingControl";
    
    return RegisterClassExW(&wcex);
}

// 사용 예
HWND CreateRatingControl(HWND hwndParent, int x, int y, int width, int height,
    int id, HINSTANCE hInstance)
{
    return CreateWindowExW(
        0,
        L"RatingControl",
        L"",
        WS_CHILD | WS_VISIBLE | WS_TABSTOP,
        x, y, width, height,
        hwndParent,
        reinterpret_cast<HMENU>(static_cast<INT_PTR>(id)),
        hInstance,
        nullptr);
}

고대비 모드 지원

2부에서 언급했듯이 접근성은 스크린 리더 지원뿐만 아니라 저시력 사용자를 위한 고대비 모드 지원도 포함합니다.

case WM_PAINT:
    {
        PAINTSTRUCT ps;
        HDC hdc = BeginPaint(hwnd, &ps);
        
        // 고대비 모드 확인
        HIGHCONTRAST hc = { sizeof(HIGHCONTRAST) };
        SystemParametersInfo(SPI_GETHIGHCONTRAST, sizeof(hc), &hc, 0);
        BOOL isHighContrast = (hc.dwFlags & HCF_HIGHCONTRASTON) != 0;
        
        // 고대비 모드에 맞는 색상 사용
        COLORREF textColor = isHighContrast ? 
            GetSysColor(COLOR_WINDOWTEXT) : RGB(255, 200, 0);
        COLORREF bgColor = isHighContrast ?
            GetSysColor(COLOR_WINDOW) : RGB(255, 255, 255);
        
        // ... 그리기 코드 ...
        
        EndPaint(hwnd, &ps);
    }
    break;

고대비 모드에서는 사용자 정의 색상 대신 시스템 색상을 사용하여 사용자의 설정을 존중합니다.

글을 맺으며

이번 4부에서는 Win32에서 Fragment Provider를 구현하여 복잡한 컨트롤의 접근성을 제공하는 방법을 살펴보았습니다.

3부의 단일 요소 Provider(커스텀 버튼)에서 한 단계 나아가, IRawElementProviderFragment와 IRawElementProviderFragmentRoot를 구현하여 UIA 트리 내에서의 탐색을 지원했습니다. 별점 위젯에 IRangeValueProvider를 구현하여 값 조정 기능을 보조 기술에 노출했습니다.

가장 중요한 것은 양방향 동기화였습니다. 2부에서 WinUI 3로 구현한 것과 동일한 개념을 Win32에서 UiaRaiseAutomationPropertyChangedEvent()로 구현했습니다. 그리고 마우스 클릭, 키보드 입력, 프로그래밍 방식의 SetValue() 호출 등 모든 입력 경로에서 UIA 이벤트를 발생시켜 스크린 리더가 변경 사항을 즉시 알 수 있도록 했습니다.

3부와 4부를 통해 Win32에서 UIA Provider를 구현하는 전체 그림을 살펴본 것입니다. WinUI 3가 자동으로 처리해주던 WM_GETOBJECT 처리, COM 참조 카운팅, VARIANT 변환, 트리 탐색, 이벤트 발생 등을 직접 구현하면서 UIA의 동작 원리를 깊이 이해할 수 있었습니다.

5부에서는 접근성 검증 도구와 체계적인 테스트 방법을 다룰 예정입니다. Accessibility Insights, Inspect.exe, AccEvent 등의 도구를 활용하여 구현한 접근성을 검증하고, 자동화된 접근성 테스트를 구축하는 방법을 살펴보겠습니다.

지금까지 긴 내용 읽어주셔서 감사합니다.

댓글 0
댓글을 작성하려면 해주세요.