TextBlock에 Binidng된 Value 변경 시 Converter가 동작하지 않는 문제

XAML

<StackPanel>
    <TextBlock Text="AAA" Margin="4"/>
    <TextBlock Text="{Binding TextString}" Margin="4"/>
    <Button Content="Click" Click="Button_Click"/>
</StackPanel>

View Model

public class ViewModel : BindingObject
{
    private string mTextString;
    public string TextString { get => mTextString; set => OnPropertyChanged(ref mTextString, value); }

    public ViewModel()
    {
        TextString = "BBB";
    }

    public void OnClick()
    {
        if (TextString == "BBB")
        {
            TextString = "CCC";
        }
        else
        {
            TextString = "BBB";
        }
    }
}

Converter

public class ConvertTextInTextBlock : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        return value; // Break Point Here
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        return value;
    }
}

ResourceDictionary

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:local="clr-namespace:TextConverterTest"
    mc:Ignorable="d">

    <local:ConvertTextInTextBlock x:Key="convertTextInTextBlock"/>

    <Style TargetType="{x:Type TextBlock}">
        <Setter Property="Text" Value="{Binding RelativeSource={RelativeSource Mode=Self}, Converter={StaticResource convertTextInTextBlock}}"/>
    </Style>

</ResourceDictionary>

Click을 통해 TextString을 업데이트했으나 Converter에 값이 전달되지 않을을 Debug를 통해서 확인했습니다.
개념적인 이해도가 아직 부족해서 문제의 원인을 알 수 없습니다.
도움을 부탁 드립니다.

Relative있고…Converter 있고…
하지만 Text가 안보이네여…?!

WPF의 DependencyProperty는 간략하게 설명하자면 크게 두 가지 모드(상태) 중 하나를 동작합니다.

  1. 우리가 알고 있는 일반 Property처럼 고정된 값을 직접 저장하는 방식
  2. Binding 등 다른 요소에 영향을 받도록 관계 지어진 상태
    두 상태를 동시에 가질 수 없고, XAML이나 코드를 통해 속성을 설정하는 과정 중에 하나의 상태가 결정되고, 또 다른 상태로 전환됩니다.

그리고, 이 답변을 위한 당연하고도 중요한 규칙 중 하나는 Style에서 속성 A를 지정한 경우, 이 Style이 적용된 객체에서 속성 A의 값을 다시 지정하면 그 값은 위에서 언급한 상태와 같이 덮어 쓰여진다는 것입니다.

현재 질문 주신 TextBlockText 속성은 내부적으로 TextBlock 의존 객체의 TextProperty 의존 속성을 사용하는 속성입니다.

코드 블럭 순서가 뒤바뀌어 있지만, 이해를 위해 ResourceDictionary 블럭이 먼저 적용되고 그 다음 XAML 블럭이 순차적으로 설정된다고 봅시다.

ResourceDictionary 블럭에서 TextBlockStyleText 속성을 지정한 시점에서는 2.의 상태를 가집니다. 여기서 지정한 Converter가 적용된 BindingBindingA라고 합시다.

XAML 블럭에 있는 두 개의 TextBlock은 ResourceDictionary에 정의되어 있는 Style이 적용된 상태입니다.
그런데, 이 시점에서 두 TextBlockText 속성을 다시 지정해 주셨습니다.

첫 번째 TextBlockText속성은 BindingA에서 "AAA"로 지정되었습니다. 이것은 1.의 상태입니다.
두 번째 TextBlockText속성은 BindingA에서 BindingB로 다시 지정되었습니다. 이것 역시 2.의 상태이지만, 원래 있던 Converter를 사용한 바인딩이 덮어쓰여진 것이죠.

그래서, XAML 블럭의 두 번째 TextBlockConverter를 타게 하고 싶으시다면,

<TextBlock Text="{Binding TextString, 
                          Converter={StaticResource convertTextInTextBlock}}"
           Margin="4"/>

와 같이 사용하시면 되겠습니다.

ResourceDictionary에 TextBlock의 Style을 변경해서 GUI에서 사용하는 모든 Text(Binding된 서로 다른 값들 포함)를 하나의 Converter를 거치도록 하는 것이 제가 원하는 방식입니다. 이러한 동작이 가능할까요?

된다라는 의견은 아니지만 테스트 해본 결과

Path=Text 를 넣어주실 경우

Converter 클래스에선
return 처리 되긴 하지만 실제 UI상에서는 지정되었던 Text 값으로만 표출되네요
image

네 그래서 Path=Text를 사용하지 않은 겁니다.

@al6uiz 님이 말씀하신 것처럼

이러한 문제로 원하시는 동작이 불가능하네요.
질문의 요지에서는 저 답으로 충분할 것으로 보입니다.


단순한 Style 설정만으로 해결할 수 있는 부분이 아니기 때문에
Style에서 Template 영역을 건드리신다면 가능합니다.

TextBlock의 경우 Template 을 만들 수 없기 때문에
Label로 변경하여 구현되는것을보여드리겠습니다.

<Style TargetType="{x:Type Label}">
    <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}" />
    <Setter Property="Background" Value="Transparent" />
    <Setter Property="Padding" Value="5" />
    <Setter Property="HorizontalContentAlignment" Value="Left" />
    <Setter Property="VerticalContentAlignment" Value="Top" />
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type Label}">
                <Border
                    Padding="{TemplateBinding Padding}"
                    Background="{TemplateBinding Background}"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}"
                    SnapsToDevicePixels="true">
                    <ContentPresenter
                        HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                        VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
                        Content="{TemplateBinding Content,
                        Converter={StaticResource TestConverter}}"
                        RecognizesAccessKey="True"
                        SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
                </Border>
                <ControlTemplate.Triggers>
                    <Trigger Property="IsEnabled" Value="false">
                        <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}" />
                    </Trigger>
                </ControlTemplate.Triggers>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

ContentPresenter 부분의 Content부분을 참고해보세요

전체 코드 아래 git 링크로 공유드립니다.

이 글에 대한 해결책을 얻고자 하신다면 조금 다른 방향으로 시도해 볼 수 있습니다.

WPF에서 사용되는 대부분의 컨트롤(TextBox의 입력 관련 렌더링이나 FlowDocument 내의 문자들 제외)은 TextBlock을 통해 문자를 표출하는데요, 이 TextBlockText 속성에 할당된 문자열을 그리기 위한 내부 객체를 생성하는 부분의 로직을 가로채서 문자열을 바꿔줄 수 있습니다.

이 방법은 무협으로 치면 :fire:사파 무공:fire:에 해당하는 방법으로 실무에 사용하는 것은 추천하지 않습니다. :sweat_smile:
런타임에 이미 컴파일 된 함수를 후킹해서 원하는 기능을 하도록 변경하는 방법으로 정석적인 방법과는 거리가 멀며 잘못 사용할 경우 소프트웨어의 안정성에 큰 영향을 줄 수도 있는 파괴적인 방법입니다.

본론으로 들어가 TextBlockText 속성 값을 어떻게 그리지는 확인하기 위해 dnSpy를 통해 OnRender 함수를 살펴보면, 위에서 언급한 Text 속성을 그리기 위한 내부 객체는 Line이라는 형식의 객체로 CreateLine이라는 함수를 통해 생성됩니다.

해당 함수의 내부 구현은 아래와 같습니다.

TextBlockInline, Run과 같이 복잡한 형식의 하위 항목을 포함하지 않고 단순히 Text 문자열 만을 포함하고 있다면 SimpleLine이라는 객체를 만들며, 이때 생성자에 Text 속성 값을 넘기도록 되어 있습니다.

line = new SimpleLine(this, this.Text, lineProperties.DefaultTextRunProperties);

우리는 이 지점에서 this.Text 부분에 대한 조건 검사를 수행한 뒤 변경된 문자열 값을 넘겨줄 것입니다.

런타임 함수를 후킹하기 위해서 프로젝트에 MonoMod.RuntimeDetour 패키지를 설치해 줍니다.

image

프로그램 진입점인 App 클래스에 다음 내용을 추가해 줍니다.

using System.Reflection;
using System.Windows;
using System.Windows.Controls;

using MonoMod.RuntimeDetour;

public partial class App : Application
{
    private static Hook _hookCreateLine; // Keep hook object from GC 
    private static PropertyInfo _piDefaultTextRunProperties;
    private static ConstructorInfo _ciSimpleLine;

    public App()
    {
        var miCreateLine = typeof(TextBlock).GetMethod(
                "CreateLine", 
                BindingFlags.NonPublic | BindingFlags.Instance);
        _hookCreateLine = new Hook(miCreateLine, CreateLine);

        var tiSimpleLine = typeof(TextBlock).Assembly.DefinedTypes
                .FirstOrDefault(x => x.Name == "SimpleLine");
        _ciSimpleLine = tiSimpleLine.DeclaredConstructors.FirstOrDefault();
    }

    private static object CreateLine(
        Func<TextBlock, object, object> orig, TextBlock self,
        object lineProperty)
    {
        if (_piDefaultTextRunProperties == null)
        {
            _piDefaultTextRunProperties = 
                lineProperty.GetType().GetProperty("DefaultTextRunProperties");
        }

        if (self.Text == "A") // 조건 검사, 
        {
            var trp = _piDefaultTextRunProperties.GetValue(lineProperty);
            var replaced = "A11"; // 변경된 값
            // SimpleLine 생성 후 리턴
            return _ciSimpleLine.Invoke(new object[] { self, replaced, trp }); 
        }
        else // 조건에 부합하지 않으면 원본 메서드를 호출
        {
            return orig(self, lineProperty);
        }
    }
}

아래와 같은 결과를 얻으실 수 있습니다.

image

관련 패키지에 대해서는 아래 링크를 참고하시면 도움이 될 듯 합니다.

추가적으로 위와 같은 변경이 필요한 컨트롤은 후킹 지점을 직접 찾아 구현해 보시기 바랍니다.

MonoMod/MonoMod.UnitTest/RuntimeDetour/HookTest.cs at master · MonoMod/MonoMod (github.com)

2 Likes

감사합니다. 내용 다 읽어보겠습니다.

1 Like

감사합니다! 두 분으로 도움으로 많은 도움이 되었습니다. 꼼꼼히 다 읽어보고 피드백 남기도록 하겠습니다!

1 Like

@al6uiz @이광석 다시 한번 도움 감사드립니다.

결론적으로 GUI내 사용되는 TextBlock을 ResourceDictionary를 통해 공용 Converter로 적용하는 것은 말씀하신대로 안될 것 같네요… Text Render를 하는 방법도 정말 고려해야 할 것들이 많아서 적용하기가 두렵더군요… 결국 각 Control의 Binding 요소마다 Converter를 적용하는 방식으로 프로젝트를 진행해야 할 것 같습니다. 감사합니다.

2 Likes