WPF 강좌, 다국어 지원 및 국제화(I18N) 방법

WPF 강좌: 다국어 지원 및 국제화(I18N) 방법

오늘날의 글로벌화된 세상에서 응용 프로그램의 다국어 지원은 매우 중요합니다. 특히 WPF(Windows Presentation Foundation)를 사용하여 개발할 때, 국제화(I18N)와 다국어 지원을 고려해야 합니다. 이 글에서는 WPF에서 다국어 지원을 구현하는 방법과 이를 위해 사용하는 기술에 대해 자세히 설명하도록 하겠습니다.

1. 국제화와 지역화의 이해

국제화(I18N, Internationalization)는 소프트웨어를 다양한 언어와 문화를 지원할 수 있도록 설계하는 과정을 말합니다. 반면 지역화(L10N, Localization)는 특정 언어와 문화에 맞는 콘텐츠를 제공하는 작업을 의미합니다. WPF에서는 이 두 가지 과정을 지원하는 다양한 기능이 있습니다.

2. WPF에서 다국어 지원을 위한 기본 개념

WPF는 XAML(Extensible Application Markup Language)을 사용하여 UI를 정의합니다. 다국어 지원을 위해서는 리소스 파일(.resx)을 사용하여 각 언어에 대한 문자열 및 기타 리소스를 관리합니다.

2.1 리소스 파일 생성

리소스 파일은 각각의 언어에 대한 고유한 문자열 데이터를 저장하는 파일입니다. Visual Studio에서 다음과 같은 방법으로 리소스 파일을 생성할 수 있습니다:

  1. Visual Studio에서 프로젝트를 마우스 오른쪽 버튼으로 클릭하고 Add -> New Item을 선택합니다.
  2. Resources File을 선택하고 파일 이름을 Strings.resx로 지정합니다.
  3. 기본 리소스 파일이 생성되고, 여기에서 각 문자열의 키와 값을 추가합니다.

2.2 문화별 리소스 파일 생성

기본 리소스 파일이 준비되면, 다른 언어를 지원하기 위해 문화별 리소스 파일을 생성해야 합니다. 예를 들어, 한국어와 영어를 지원하고 싶다면 다음과 같은 파일을 생성합니다:

  • Strings.en.resx (영어)
  • Strings.ko.resx (한국어)

각 파일에서 해당 언어에 맞는 문자열을 입력합니다. 이후에 WPF는 현재 문화에 맞는 리소스 파일을 자동으로 사용합니다.

3. UI에 리소스 파일 연결하기

리소스 파일을 준비한 후, XAML 파일에서 이를 사용하는 방법은 다음과 같습니다. WPF에서는 {x:Static} 마크업 확장을 사용하여 리소스 파일의 값을 가져올 수 있습니다.

3.1 리소스 파일 사용 예제

예를 들어, 버튼의 텍스트를 다국어로 지원하도록 설정할 수 있습니다:

<Button Content="{x:Static properties:Strings.MyButtonText}" />

여기서 MyButtonText는 리소스 파일에서 정의한 문자열의 키입니다. 버튼의 텍스트는 현재 문화에 따라 적절한 문자열 값으로 표시됩니다.

4. 현재 문화 변경하기

응용 프로그램에서 사용자가 언어를 직접 변경할 수 있도록 하려면, 현재 문화 정보를 변경해야 합니다. 다음은 현재 문화 정보를 변경하는 방법의 예입니다:

CultureInfo.CurrentUICulture = new CultureInfo("ko-KR");

위 코드는 현재 UI 문화를 한국어로 설정합니다. 이를 통해 사용자는 원활하게 다양한 언어를 사용할 수 있습니다.

5. 날짜 및 숫자 형식 처리

국제화된 응용 프로그램에서 숫자와 날짜의 형식은 매우 중요합니다. WPF에서는 CultureInfo를 사용하여 이러한 형식을 처리할 수 있습니다. 예를 들어, 날짜를 현재 문화에 맞게 형식화하려면 DateTime 개체의 ToString 메서드를 사용할 수 있습니다:

string formattedDate = DateTime.Now.ToString("D", CultureInfo.CurrentCulture);

위 코드는 현재 날짜를 현재 문화에 맞는 형식으로 반환합니다.

6. 정리 및 결론

WPF에서 다국어 지원 및 국제화를 구현하는 것은 현대 소프트웨어 개발에서 필수적인 요소입니다. 우리는 리소스 파일을 생성하고, UI 요소와 연결하며, 현재 문화를 변경하는 방법에 대해 알아보았습니다. 마지막으로, 숫자와 날짜의 형식을 처리하는 방법도 살펴보았습니다.

이 과정들을 통해 WPF 응용 프로그램이 다양한 문화적 배경을 가진 사용자에게 친숙하고 이해하기 쉬운 경험을 제공할 수 있도록 만드는 것이 목표입니다. 이를 통해 글로벌 시장에서 경쟁력을 갖춘 소프트웨어를 개발할 수 있을 것입니다.

xml 파싱
public void LoadXml(string filepath, Rect? area = null)
{
    ViewModel.Shapes.Clear();
    var shapes = BaseShapeViewModel.LoadFromXml(filepath);
    
    if (area.HasValue)
    {
        // 선택된 영역 내의 도형만 필터링
        var filteredShapes = shapes.Where(shape => 
        {
            var shapeBounds = new Rect(shape.X, shape.Y, shape.Width, shape.Height);
            return area.Value.IntersectsWith(shapeBounds);
        });

        foreach (var shape in filteredShapes)
        {
            // 도형의 좌표를 area 기준으로 조정
            shape.X -= area.Value.X;
            shape.Y -= area.Value.Y;
            ViewModel.Shapes.Add(shape);
        }

        // Canvas 크기를 area 크기로 조정
        MainCanvas.Width = area.Value.Width;
        MainCanvas.Height = area.Value.Height;
    }
    else
    {
        // 전체 도형 로드
        foreach (var shape in shapes)
        {
            ViewModel.Shapes.Add(shape);
        }
    }

    FitCanvasToView();
}
multi-combo
<!-- MultiSelectComboBox.xaml -->
<UserControl x:Class="wpf_FMB.MultiSelectComboBox"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:wpf_FMB">
    <UserControl.Resources>
        <!-- Toggle Button Style -->
        <Style x:Key="ComboBoxToggleButton" TargetType="{x:Type ToggleButton}">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type ToggleButton}">
                        <Border x:Name="Border" 
                                Background="#FAFAFA"
                                BorderBrush="#E8E8E8"
                                BorderThickness="1"
                                CornerRadius="6">
                            <Grid>
                                <Grid.ColumnDefinitions>
                                    <ColumnDefinition/>
                                    <ColumnDefinition Width="32"/>
                                </Grid.ColumnDefinitions>
                                <TextBlock x:Name="DisplayText" 
                                         Margin="12,0,0,0"
                                         VerticalAlignment="Center"
                                         Foreground="#333333"
                                         FontSize="13"
                                         Text="{Binding RelativeSource={RelativeSource AncestorType=local:MultiSelectComboBox}, Path=DisplayText}"/>
                                <Path Grid.Column="1" 
                                      Data="M0,0 L6,6 L12,0" 
                                      Stroke="#666666"
                                      StrokeThickness="1.5"
                                      HorizontalAlignment="Center"
                                      VerticalAlignment="Center"
                                      Width="12"
                                      Height="6"/>
                            </Grid>
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter Property="Background" Value="#F5F5F5" TargetName="Border"/>
                            </Trigger>
                            <Trigger Property="IsChecked" Value="True">
                                <Setter Property="Background" Value="#F0F0F0" TargetName="Border"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>

        <!-- Checkbox Style -->
        <Style x:Key="CustomCheckBox" TargetType="{x:Type CheckBox}">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type CheckBox}">
                        <Grid>
                            <Border x:Name="Border"
                                    Width="16" 
                                    Height="16"
                                    Background="White"
                                    BorderBrush="#DDDDDD"
                                    BorderThickness="1"
                                    CornerRadius="3">
                                <Path x:Name="Checkmark" 
                                      Data="M2,6 L6,10 L14,2"
                                      Stroke="White"
                                      StrokeThickness="2"
                                      Visibility="Collapsed"/>
                            </Border>
                        </Grid>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsChecked" Value="True">
                                <Setter Property="Background" Value="#1677FF" TargetName="Border"/>
                                <Setter Property="BorderBrush" Value="#1677FF" TargetName="Border"/>
                                <Setter Property="Visibility" Value="Visible" TargetName="Checkmark"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>

        <!-- ComboBoxItem Style -->
        <Style x:Key="ComboBoxItemStyle" TargetType="{x:Type ComboBoxItem}">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type ComboBoxItem}">
                        <Border x:Name="Border" 
                                Padding="8,8,12,8"
                                Background="Transparent">
                            <Grid>
                                <Grid.ColumnDefinitions>
                                    <ColumnDefinition Width="Auto"/>
                                    <ColumnDefinition/>
                                </Grid.ColumnDefinitions>
                                <CheckBox IsChecked="{Binding IsSelected}" 
                                        Style="{StaticResource CustomCheckBox}"
                                        Margin="0,0,8,0"
                                        VerticalAlignment="Center"/>
                                <TextBlock Text="{Binding Text}" 
                                         Grid.Column="1"
                                         FontSize="13"
                                         Foreground="#333333"
                                         VerticalAlignment="Center"/>
                            </Grid>
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter Property="Background" Value="#F5F5F5" TargetName="Border"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </UserControl.Resources>

    <Grid>
        <ToggleButton x:Name="PART_ToggleButton" 
                      Style="{StaticResource ComboBoxToggleButton}"
                      Height="36"/>
        
        <Popup x:Name="PART_Popup" 
               PlacementTarget="{Binding ElementName=PART_ToggleButton}"
               IsOpen="{Binding IsChecked, ElementName=PART_ToggleButton}"
               StaysOpen="False"
               AllowsTransparency="True">
            <Border Background="White" 
                    BorderBrush="#E8E8E8" 
                    BorderThickness="1"
                    CornerRadius="6"
                    Margin="0,4,0,0">
                <Border.Effect>
                    <DropShadowEffect BlurRadius="8" 
                                    ShadowDepth="2" 
                                    Opacity="0.15" 
                                    Color="#000000"/>
                </Border.Effect>
                <ItemsControl ItemsSource="{Binding Items}"
                            MaxHeight="300"
                            ScrollViewer.VerticalScrollBarVisibility="Auto">
                    <ItemsControl.ItemTemplate>
                        <DataTemplate>
                            <ComboBoxItem Style="{StaticResource ComboBoxItemStyle}"/>
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
            </Border>
        </Popup>
    </Grid>
</UserControl>

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Linq;

namespace wpf_FMB
{
    public class ComboBoxItem : INotifyPropertyChanged
    {
        private string _text;
        private bool _isSelected;

        public string Text
        {
            get => _text;
            set
            {
                _text = value;
                OnPropertyChanged(nameof(Text));
            }
        }

        public bool IsSelected
        {
            get => _isSelected;
            set
            {
                if (_isSelected != value)
                {
                    _isSelected = value;
                    OnPropertyChanged(nameof(IsSelected));
                }
            }
        }

        public event PropertyChangedEventHandler PropertyChanged;

        protected virtual void OnPropertyChanged(string propertyName)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    public partial class MultiSelectComboBox : UserControl, INotifyPropertyChanged
    {
        public static readonly DependencyProperty PlaceholderTextProperty =
            DependencyProperty.Register("PlaceholderText", typeof(string), typeof(MultiSelectComboBox), 
                new PropertyMetadata("Select items..."));

        public string PlaceholderText
        {
            get => (string)GetValue(PlaceholderTextProperty);
            set => SetValue(PlaceholderTextProperty, value);
        }

        private ObservableCollection<ComboBoxItem> _items;
        public ObservableCollection<ComboBoxItem> Items
        {
            get => _items;
            set
            {
                _items = value;
                OnPropertyChanged(nameof(Items));
                UpdateDisplayText();
            }
        }

        public string DisplayText
        {
            get
            {
                var selectedItems = Items?.Where(i => i.IsSelected).Select(i => i.Text).ToList();
                if (selectedItems == null || !selectedItems.Any())
                    return PlaceholderText;
                
                if (selectedItems.Count <= 2)
                    return string.Join(", ", selectedItems);
                
                return $"{selectedItems[0]}, {selectedItems[1]}, +{selectedItems.Count - 2}";
            }
        }

        public MultiSelectComboBox()
        {
            InitializeComponent();
            DataContext = this;
            Items = new ObservableCollection<ComboBoxItem>();

            Items.CollectionChanged += (s, e) => UpdateDisplayText();
        }

        private void UpdateDisplayText()
        {
            OnPropertyChanged(nameof(DisplayText));
        }

        public event PropertyChangedEventHandler PropertyChanged;

        protected virtual void OnPropertyChanged(string propertyName)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        public void AddItem(string text, bool isSelected = false)
        {
            var item = new ComboBoxItem { Text = text, IsSelected = isSelected };
            item.PropertyChanged += (s, e) =>
            {
                if (e.PropertyName == nameof(ComboBoxItem.IsSelected))
                {
                    UpdateDisplayText();
                }
            };
            Items.Add(item);
        }
    }
}
progressbar
<UserControl x:Class="YourNamespace.STKInspectionControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <StackPanel>
        <TextBlock Text="title" 
                 FontWeight="SemiBold" 
                 FontSize="13"
                 Margin="0,0,0,8"/>
        
        <StackPanel Margin="0,4">
            <!-- STK01 -->
            <DockPanel>
                <TextBlock Text="STK01" 
                         FontSize="12" 
                         VerticalAlignment="Center"/>
                <TextBlock Text="81/98" 
                         FontSize="12"
                         Foreground="#666666"
                         HorizontalAlignment="Right"
                         Margin="0,0,8,0"/>
                <TextBlock Text="82%" 
                         FontSize="12"
                         Foreground="#666666"
                         HorizontalAlignment="Right"/>
            </DockPanel>
            <ProgressBar Value="82" 
                       Maximum="100" 
                       Height="6" 
                       Margin="0,2">
                <ProgressBar.Style>
                    <Style TargetType="ProgressBar">
                        <Setter Property="Background" Value="#F5F5F5"/>
                        <Setter Property="Foreground" Value="#2196F3"/>
                        <Style.Triggers>
                            <DataTrigger Binding="{Binding Value, RelativeSource={RelativeSource Self}, 
                                        Converter={StaticResource GreaterThanConverter}, ConverterParameter=90}" 
                                       Value="True">
                                <Setter Property="Foreground" Value="#1976D2"/>
                            </DataTrigger>
                        </Style.Triggers>
                    </Style>
                </ProgressBar.Style>
            </ProgressBar>

            <!-- STK02 -->
            <DockPanel Margin="0,8,0,0">
                <TextBlock Text="STK02" 
                         FontSize="12"
                         VerticalAlignment="Center"/>
                <TextBlock Text="97/98" 
                         FontSize="12"
                         Foreground="#666666"
                         HorizontalAlignment="Right"
                         Margin="0,0,8,0"/>
                <TextBlock Text="99%" 
                         FontSize="12"
                         Foreground="#666666"
                         HorizontalAlignment="Right"/>
            </DockPanel>
            <ProgressBar Value="99"
                       Maximum="100"
                       Height="6"
                       Margin="0,2">
                <ProgressBar.Style>
                    <Style TargetType="ProgressBar">
                        <Setter Property="Background" Value="#F5F5F5"/>
                        <Setter Property="Foreground" Value="#2196F3"/>
                        <Style.Triggers>
                            <DataTrigger Binding="{Binding Value, RelativeSource={RelativeSource Self}, 
                                        Converter={StaticResource GreaterThanConverter}, ConverterParameter=90}" 
                                       Value="True">
                                <Setter Property="Foreground" Value="#1976D2"/>
                            </DataTrigger>
                        </Style.Triggers>
                    </Style>
                </ProgressBar.Style>
            </ProgressBar>

            <!-- STK03 -->
            <DockPanel Margin="0,8,0,0">
                <TextBlock Text="STK03" 
                         FontSize="12"
                         VerticalAlignment="Center"/>
                <TextBlock Text="81/98" 
                         FontSize="12"
                         Foreground="#666666"
                         HorizontalAlignment="Right"
                         Margin="0,0,8,0"/>
                <TextBlock Text="82%" 
                         FontSize="12"
                         Foreground="#666666"
                         HorizontalAlignment="Right"/>
            </DockPanel>
            <ProgressBar Value="82"
                       Maximum="100" 
                       Height="6"
                       Margin="0,2">
                <ProgressBar.Style>
                    <Style TargetType="ProgressBar">
                        <Setter Property="Background" Value="#F5F5F5"/>
                        <Setter Property="Foreground" Value="#2196F3"/>
                        <Style.Triggers>
                            <DataTrigger Binding="{Binding Value, RelativeSource={RelativeSource Self}, 
                                        Converter={StaticResource GreaterThanConverter}, ConverterParameter=90}" 
                                       Value="True">
                                <Setter Property="Foreground" Value="#1976D2"/>
                            </DataTrigger>
                        </Style.Triggers>
                    </Style>
                </ProgressBar.Style>
            </ProgressBar>
        </StackPanel>
    </StackPanel>
</UserControl>

using System;
using System.Globalization;
using System.Windows.Data;

namespace YourNamespace
{
    public class GreaterThanConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            if (value is double doubleValue && parameter is string stringParameter)
            {
                if (double.TryParse(stringParameter, out double threshold))
                {
                    return doubleValue > threshold;
                }
            }
            return false;
        }

        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
}
port graph
&lt;ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:YourNamespace">

    &lt;Style TargetType="{x:Type local:PortStatsChart}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:PortStatsChart}">
                    <Grid>
                        <!-- Chart Canvas -->
                        <Canvas x:Name="PART_Canvas" />
                        
                        <!-- Legend -->
                        <ItemsControl ItemsSource="{TemplateBinding ItemsSource}"
                                    VerticalAlignment="Bottom"
                                    Margin="0,20,0,10">
                            <ItemsControl.ItemsPanel>
                                <ItemsPanelTemplate>
                                    <WrapPanel />
                                </ItemsPanelTemplate>
                            </ItemsControl.ItemsPanel>
                            <ItemsControl.ItemTemplate>
                                <DataTemplate>
                                    <Border Margin="8,4"
                                            Padding="8,4"
                                            Background="#F8F8F8"
                                            CornerRadius="4"
                                            BorderThickness="1"
                                            BorderBrush="#E5E5E5">
                                        <StackPanel Orientation="Horizontal" Spacing="8">
                                            <Rectangle Width="12" 
                                                     Height="12" 
                                                     Fill="{Binding Color}"
                                                     RadiusX="2"
                                                     RadiusY="2" />
                                            <TextBlock Text="{Binding Name}" 
                                                     FontWeight="SemiBold"
                                                     VerticalAlignment="Center"/>
                                            <TextBlock Text="|"
                                                     Foreground="#666666"
                                                     VerticalAlignment="Center"/>
                                            <TextBlock VerticalAlignment="Center">
                                                <Run Text="{Binding Count}"/>
                                                <Run Text="개"/>
                                            </TextBlock>
                                        </StackPanel>
                                    </Border>
                                </DataTemplate>
                            </ItemsControl.ItemTemplate>
                        </ItemsControl>

                        <!-- Tooltip -->
                        <Popup x:Name="PART_Tooltip" 
                               AllowsTransparency="True"
                               Placement="Relative"
                               PlacementTarget="{Binding ElementName=PART_Canvas}"
                               IsOpen="False">
                            <Border Background="#CC000000"
                                    CornerRadius="4"
                                    Padding="8,6">
                                <TextBlock x:Name="PART_TooltipText"
                                         Foreground="White"
                                         FontWeight="Medium"/>
                            </Border>
                        </Popup>
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    &lt;/Style>

&lt;/ResourceDictionary>

// PortStatItem.cs
public class PortStatItem : INotifyPropertyChanged
{
    private string _name;
    private int _count;
    private Color _color;

    public string Name
    {
        get => _name;
        set
        {
            _name = value;
            OnPropertyChanged();
        }
    }

    public int Count
    {
        get => _count;
        set
        {
            _count = value;
            OnPropertyChanged();
        }
    }

    public Color Color
    {
        get => _color;
        set
        {
            _color = value;
            OnPropertyChanged();
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

// PortStatsChart.cs
public class PortStatsChart : Control
{
    static PortStatsChart()
    {
        DefaultStyleKeyProperty.OverrideMetadata(
            typeof(PortStatsChart),
            new FrameworkPropertyMetadata(typeof(PortStatsChart)));
    }

    public static readonly DependencyProperty ItemsSourceProperty =
        DependencyProperty.Register(
            nameof(ItemsSource),
            typeof(IEnumerable<PortStatItem>),
            typeof(PortStatsChart),
            new PropertyMetadata(null, OnItemsSourceChanged));

    public IEnumerable<PortStatItem> ItemsSource
    {
        get => (IEnumerable<PortStatItem>)GetValue(ItemsSourceProperty);
        set => SetValue(ItemsSourceProperty, value);
    }

    private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is PortStatsChart chart)
        {
            chart.InvalidateVisual();
        }
    }

    protected override void OnRender(DrawingContext drawingContext)
    {
        base.OnRender(drawingContext);

        if (ItemsSource == null || !ItemsSource.Any()) return;

        var centerX = ActualWidth / 2;
        var centerY = ActualHeight * 0.4; // Move center up to make room for legend
        var radius = Math.Min(centerX, centerY) * 0.8;
        var innerRadius = radius * 0.6; // For donut hole

        var total = ItemsSource.Sum(item => item.Count);
        var startAngle = 180.0;
        
        foreach (var item in ItemsSource)
        {
            var sweepAngle = (item.Count / (double)total) * 180.0;
            
            var path = CreateArcGeometry(
                centerX, centerY,
                radius, innerRadius,
                startAngle, sweepAngle);

            var brush = new SolidColorBrush(item.Color);
            drawingContext.DrawGeometry(brush, new Pen(Brushes.White, 1), path);
            
            startAngle += sweepAngle;
        }
    }

    private Geometry CreateArcGeometry(
        double centerX, double centerY,
        double radius, double innerRadius,
        double startAngle, double sweepAngle)
    {
        var startRadian = startAngle * Math.PI / 180;
        var endRadian = (startAngle + sweepAngle) * Math.PI / 180;

        var outerStart = new Point(
            centerX + radius * Math.Cos(startRadian),
            centerY + radius * Math.Sin(startRadian));
        var outerEnd = new Point(
            centerX + radius * Math.Cos(endRadian),
            centerY + radius * Math.Sin(endRadian));
        var innerStart = new Point(
            centerX + innerRadius * Math.Cos(startRadian),
            centerY + innerRadius * Math.Sin(startRadian));
        var innerEnd = new Point(
            centerX + innerRadius * Math.Cos(endRadian),
            centerY + innerRadius * Math.Sin(endRadian));

        var figure = new PathFigure { StartPoint = outerStart };
        figure.Segments.Add(new ArcSegment(
            outerEnd,
            new Size(radius, radius),
            0,
            false,
            SweepDirection.Counterclockwise,
            true));
        figure.Segments.Add(new LineSegment(innerEnd, true));
        figure.Segments.Add(new ArcSegment(
            innerStart,
            new Size(innerRadius, innerRadius),
            0,
            false,
            SweepDirection.Clockwise,
            true));
        figure.IsClosed = true;

        return new PathGeometry { Figures = { figure } };
    }
}
checked combo
public class CheckBoxItem : INotifyPropertyChanged
{
    private bool _isChecked;
    private string _content;

    public bool IsChecked
    {
        get => _isChecked;
        set
        {
            _isChecked = value;
            OnPropertyChanged();
        }
    }

    public string Content
    {
        get => _content;
        set
        {
            _content = value;
            OnPropertyChanged();
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

public class CheckBoxComboBox : ComboBox
{
    public static readonly DependencyProperty SelectedItemsProperty =
        DependencyProperty.Register(nameof(SelectedItems), typeof(ObservableCollection<CheckBoxItem>),
            typeof(CheckBoxComboBox), new PropertyMetadata(new ObservableCollection<CheckBoxItem>()));

    public static readonly DependencyProperty TextProperty =
        DependencyProperty.Register(nameof(Text), typeof(string),
            typeof(CheckBoxComboBox), new PropertyMetadata(string.Empty));

    public ObservableCollection<CheckBoxItem> SelectedItems
    {
        get => (ObservableCollection<CheckBoxItem>)GetValue(SelectedItemsProperty);
        set => SetValue(SelectedItemsProperty, value);
    }

    public string Text
    {
        get => (string)GetValue(TextProperty);
        set => SetValue(TextProperty, value);
    }

    public CheckBoxComboBox()
    {
        SelectedItems = new ObservableCollection<CheckBoxItem>();
    }

    static CheckBoxComboBox()
    {
        DefaultStyleKeyProperty.OverrideMetadata(typeof(CheckBoxComboBox),
            new FrameworkPropertyMetadata(typeof(CheckBoxComboBox)));
    }

    protected override void OnItemsSourceChanged(IEnumerable oldValue, IEnumerable newValue)
    {
        base.OnItemsSourceChanged(oldValue, newValue);

        if (oldValue is INotifyCollectionChanged oldCollection)
        {
            oldCollection.CollectionChanged -= OnItemsChanged;
        }

        if (newValue is INotifyCollectionChanged newCollection)
        {
            newCollection.CollectionChanged += OnItemsChanged;
            UpdateText();
        }
    }

    private void OnItemsChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.NewItems != null)
        {
            foreach (CheckBoxItem item in e.NewItems)
            {
                item.PropertyChanged += OnItemPropertyChanged;
            }
        }

        if (e.OldItems != null)
        {
            foreach (CheckBoxItem item in e.OldItems)
            {
                item.PropertyChanged -= OnItemPropertyChanged;
            }
        }
    }

    private void OnItemPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName == nameof(CheckBoxItem.IsChecked))
        {
            UpdateText();
        }
    }

    private void UpdateText()
    {
        if (ItemsSource == null) return;

        var selectedItems = ((IEnumerable<CheckBoxItem>)ItemsSource)
            .Where(item => item.IsChecked)
            .Select(item => item.Content)
            .ToList();

        if (selectedItems.Count == 0)
        {
            Text = string.Empty;
            return;
        }

        if (selectedItems.Count <= 2)
        {
            Text = string.Join(", ", selectedItems);
        }
        else
        {
            Text = $"{selectedItems[0]}, {selectedItems[1]}, +{selectedItems.Count - 2}";
        }
    }
}

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:wpf_FMB">

    <Style TargetType="{x:Type local:CheckBoxComboBox}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:CheckBoxComboBox}">
                    <Grid>
                        <ToggleButton x:Name="ToggleButton"
                                    BorderBrush="#E8E8E8"
                                    Background="#FAFAFA"
                                    IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
                            <ToggleButton.Template>
                                <ControlTemplate>
                                    <Border x:Name="Border" 
                                            Background="{TemplateBinding Background}"
                                            BorderBrush="{TemplateBinding BorderBrush}"
                                            BorderThickness="1"
                                            CornerRadius="6">
                                        <Grid>
                                            <Grid.ColumnDefinitions>
                                                <ColumnDefinition/>
                                                <ColumnDefinition Width="32"/>
                                            </Grid.ColumnDefinitions>
                                            
                                            <TextBlock Text="{Binding Text, RelativeSource={RelativeSource AncestorType=local:CheckBoxComboBox}}"
                                                     Margin="12,0,0,0"
                                                     FontSize="13"
                                                     Foreground="#333333"
                                                     VerticalAlignment="Center"/>
                                            
                                            <Path Grid.Column="1" 
                                                  Data="M0,0 L6,6 L12,0"
                                                  Stroke="#666666"
                                                  StrokeThickness="1.5"
                                                  HorizontalAlignment="Center"
                                                  VerticalAlignment="Center"/>
                                        </Grid>
                                    </Border>
                                    <ControlTemplate.Triggers>
                                        <Trigger Property="IsMouseOver" Value="True">
                                            <Setter Property="Background" Value="#F5F5F5"/>
                                        </Trigger>
                                        <Trigger Property="IsChecked" Value="True">
                                            <Setter Property="Background" Value="#F0F0F0"/>
                                        </Trigger>
                                    </ControlTemplate.Triggers>
                                </ControlTemplate>
                            </ToggleButton.Template>
                        </ToggleButton>

                        <Popup IsOpen="{TemplateBinding IsDropDownOpen}"
                               Placement="Bottom"
                               AllowsTransparency="True">
                            <Border Background="White"
                                    BorderBrush="#E8E8E8"
                                    BorderThickness="1"
                                    CornerRadius="6"
                                    Margin="0,4,0,0">
                                <Border.Effect>
                                    <DropShadowEffect BlurRadius="8" 
                                                    ShadowDepth="2" 
                                                    Opacity="0.15"/>
                                </Border.Effect>
                                
                                <ScrollViewer MaxHeight="300" 
                                            VerticalScrollBarVisibility="Auto">
                                    <StackPanel>
                                        <ItemsPresenter/>
                                    </StackPanel>
                                </ScrollViewer>
                            </Border>
                        </Popup>
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>

        <Setter Property="ItemTemplate">
            <Setter.Value>
                <DataTemplate>
                    <CheckBox Margin="8,8,12,8" 
                             IsChecked="{Binding IsChecked, Mode=TwoWay}">
                        <CheckBox.Template>
                            <ControlTemplate TargetType="CheckBox">
                                <Grid Background="Transparent">
                                    <Grid.ColumnDefinitions>
                                        <ColumnDefinition Width="Auto"/>
                                        <ColumnDefinition/>
                                    </Grid.ColumnDefinitions>
                                    
                                    <Border x:Name="CheckBoxBorder"
                                            Width="16" Height="16"
                                            Background="White"
                                            BorderBrush="#DDDDDD"
                                            BorderThickness="1"
                                            CornerRadius="3"
                                            Margin="0,0,8,0">
                                        <Path x:Name="CheckMark"
                                              Data="M2,6 L6,10 L14,2"
                                              Stroke="White"
                                              StrokeThickness="2"
                                              Visibility="Collapsed"/>
                                    </Border>
                                    
                                    <TextBlock Grid.Column="1"
                                             Text="{Binding Content}"
                                             FontSize="13"
                                             Foreground="#333333"
                                             VerticalAlignment="Center"/>
                                </Grid>
                                <ControlTemplate.Triggers>
                                    <Trigger Property="IsChecked" Value="True">
                                        <Setter TargetName="CheckBoxBorder" Property="Background" Value="#1677FF"/>
                                        <Setter TargetName="CheckBoxBorder" Property="BorderBrush" Value="#1677FF"/>
                                        <Setter TargetName="CheckMark" Property="Visibility" Value="Visible"/>
                                    </Trigger>
                                    <Trigger Property="IsMouseOver" Value="True">
                                        <Setter Property="Background" Value="#F5F5F5"/>
                                    </Trigger>
                                </ControlTemplate.Triggers>
                            </ControlTemplate>
                        </CheckBox.Template>
                    </CheckBox>
                </DataTemplate>
            </Setter.Value>
        </Setter>
    </Style>

</ResourceDictionary>
status
<?xml version="1.0" encoding="utf-8"?>
<UserControl x:Class="WpfApp.StatusPanel"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:WpfApp">

    <UserControl.Resources>
        <ObservableCollection x:Key="StatusItems"
                             xmlns="clr-namespace:System.Collections.ObjectModel;assembly=System"
                             xmlns:local="clr-namespace:WpfApp">
            <local:StatusItem Name="Run" IconColor="#4CAF50" Count="45"/>
            <local:StatusItem Name="Ready" IconColor="#FFC107" Count="32"/>
            <local:StatusItem Name="PREPD" IconColor="#FF9800" Count="15"/>
            <local:StatusItem Name="LOADD" IconColor="#9E9E9E" Count="28"/>
            <local:StatusItem Name="BM" IconColor="#F44336" Count="12"/>
            <local:StatusItem Name="CALLM" IconColor="#E91E63" Count="8"/>
            <local:StatusItem Name="ERROR" IconColor="#FF0000" Count="5"/>
            <local:StatusItem Name="MAINT" IconColor="#2196F3" Count="3"/>
        </ObservableCollection>

        <Style x:Key="AllButtonStyle" TargetType="Button">
            <Setter Property="Background" Value="#EEEEEE"/>
            <Setter Property="BorderBrush" Value="#DDDDDD"/>
            <Setter Property="BorderThickness" Value="1"/>
            <Setter Property="Padding" Value="8,2"/>
            <Setter Property="Height" Value="22"/>
            <Setter Property="FontSize" Value="11"/>
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="Button">
                        <Border Background="{TemplateBinding Background}"
                                BorderBrush="{TemplateBinding BorderBrush}"
                                BorderThickness="{TemplateBinding BorderThickness}"
                                CornerRadius="4">
                            <ContentPresenter HorizontalAlignment="Center" 
                                            VerticalAlignment="Center"/>
                        </Border>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>

        <Style x:Key="StatusItemStyle" TargetType="Grid">
            <Setter Property="Margin" Value="0,3"/>
            <Setter Property="Height" Value="26"/>
        </Style>

        <Style x:Key="StatusIconStyle" TargetType="Border">
            <Setter Property="Width" Value="20"/>
            <Setter Property="Height" Value="20"/>
            <Setter Property="CornerRadius" Value="4"/>
            <Setter Property="Margin" Value="0,0,8,0"/>
        </Style>

        <local:TotalCountConverter x:Key="TotalCountConverter"/>
    </UserControl.Resources>

    <Border Background="White" 
            CornerRadius="8"
            BorderThickness="1"
            BorderBrush="#E5E5E5">
        <StackPanel Margin="16,12,16,16">
            <Grid Height="28">
                <DockPanel>
                    <TextBlock Text="장비상태" 
                             FontSize="13"
                             FontWeight="SemiBold"
                             VerticalAlignment="Center"/>
                    <Button Content="ALL" 
                            Style="{StaticResource AllButtonStyle}"
                            Width="40"
                            Margin="8,0"/>
                    <TextBlock VerticalAlignment="Center"
                             HorizontalAlignment="Right"
                             Foreground="#666666">
                        <TextBlock.Text>
                            <MultiBinding Converter="{StaticResource TotalCountConverter}">
                                <Binding Source="{StaticResource StatusItems}" Path="Count"/>
                            </MultiBinding>
                        </TextBlock.Text>
                    </TextBlock>
                </DockPanel>
            </Grid>

            <ItemsControl x:Name="StatusList" Margin="0,8,0,0">
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <Grid Style="{StaticResource StatusItemStyle}">
                            <DockPanel>
                                <Border Style="{StaticResource StatusIconStyle}"
                                        Background="{Binding IconColor}"/>
                                <TextBlock Text="{Binding Name}" 
                                         VerticalAlignment="Center"
                                         FontSize="12"/>
                                <TextBlock Text="{Binding CountDisplay}"
                                         HorizontalAlignment="Right"
                                         VerticalAlignment="Center"
                                         FontSize="12"
                                         Foreground="#666666"/>
                            </DockPanel>
                        </Grid>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>

            <Button Content="상태 더보기 ▼"
                    Background="Transparent"
                    BorderThickness="0"
                    HorizontalAlignment="Center"
                    Margin="0,8,0,0"
                    Padding="12,4"
                    FontSize="11"
                    Foreground="#666666"
                    Click="ToggleExpand_Click"/>
        </StackPanel>
    </Border>
</UserControl>

using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;

namespace WpfApp
{
    public partial class StatusPanel : UserControl
    {
        private bool isExpanded = false;

        public StatusPanel()
        {
            InitializeComponent();
        }

        private void ToggleExpand_Click(object sender, RoutedEventArgs e)
        {
            isExpanded = !isExpanded;
            var button = sender as Button;
            if (button != null)
            {
                button.Content = isExpanded ? "상태 숨기기 ▲" : "상태 더보기 ▼";
            }
        }
    }

    public class StatusItem : INotifyPropertyChanged
    {
        private string name;
        private string iconColor;
        private int count;

        public event PropertyChangedEventHandler PropertyChanged;

        public string Name
        {
            get => name;
            set
            {
                name = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
            }
        }

        public string IconColor
        {
            get => iconColor;
            set
            {
                iconColor = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IconColor)));
            }
        }

        public int Count
        {
            get => count;
            set
            {
                count = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count)));
            }
        }
    }
}
font-size
using System.Windows;
using System.Windows.Media;
using System.Globalization;

public static class TextMeasurement
{
    public static Size MeasureText(string text, string fontFamily, double fontSize)
    {
        var formattedText = new FormattedText(
            text,
            CultureInfo.CurrentCulture,
            FlowDirection.LeftToRight,
            new Typeface(fontFamily),
            fontSize,
            Brushes.Black,
            VisualTreeHelper.GetDpi(Application.Current.MainWindow).PixelsPerDip);

        return new Size(formattedText.Width, formattedText.Height);
    }
}

// Usage example:
// Size textSize = TextMeasurement.MeasureText("Sample Text", "Arial", 14);
// double width = textSize.Width;
// double height = textSize.Height;
animation
// PortStatItem.cs
public class PortStatItem : INotifyPropertyChanged
{
    private string _name;
    private int _count;
    private Color _color;

    public string Name
    {
        get => _name;
        set
        {
            _name = value;
            OnPropertyChanged();
        }
    }

    public int Count
    {
        get => _count;
        set
        {
            _count = value;
            OnPropertyChanged();
        }
    }

    public Color Color
    {
        get => _color;
        set
        {
            _color = value;
            OnPropertyChanged();
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

// PortStatsChart.cs
public class PortStatsChart : Control
{
    private readonly List<double> _currentAngles = new();
    private readonly List<double> _targetAngles = new();
    private readonly Storyboard _animationStoryboard = new();
    private bool _isAnimating;
    static PortStatsChart()
    {
        DefaultStyleKeyProperty.OverrideMetadata(
            typeof(PortStatsChart),
            new FrameworkPropertyMetadata(typeof(PortStatsChart)));
    }

    public static readonly DependencyProperty ItemsSourceProperty =
        DependencyProperty.Register(
            nameof(ItemsSource),
            typeof(IEnumerable<PortStatItem>),
            typeof(PortStatsChart),
            new PropertyMetadata(null, OnItemsSourceChanged));

    public IEnumerable<PortStatItem> ItemsSource
    {
        get => (IEnumerable<PortStatItem>)GetValue(ItemsSourceProperty);
        set => SetValue(ItemsSourceProperty, value);
    }

    private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is PortStatsChart chart)
        {
            chart.StartAnimation();
        }
    }

    private void StartAnimation()
    {
        _animationStoryboard.Stop();
        _animationStoryboard.Children.Clear();
        
        if (ItemsSource == null || !ItemsSource.Any()) return;

        var total = ItemsSource.Sum(item => item.Count);
        var startAngle = 180.0;

        _targetAngles.Clear();
        foreach (var item in ItemsSource)
        {
            var sweepAngle = (item.Count / (double)total) * 180.0;
            _targetAngles.Add(startAngle + sweepAngle);
            startAngle += sweepAngle;
        }

        // Initialize current angles if needed
        if (_currentAngles.Count != _targetAngles.Count)
        {
            _currentAngles.Clear();
            _currentAngles.AddRange(Enumerable.Repeat(180.0, _targetAngles.Count));
        }

        var animation = new DoubleAnimation
        {
            From = 0,
            To = 1,
            Duration = TimeSpan.FromMilliseconds(500),
            EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
        };

        _isAnimating = true;
        animation.Completed += (s, e) => _isAnimating = false;

        var clockGroup = new AnimationClock(animation, true);
        clockGroup.CurrentTimeInvalidated += (s, e) =>
        {
            if (clockGroup.CurrentProgress.HasValue)
            {
                for (int i = 0; i < _currentAngles.Count; i++)
                {
                    _currentAngles[i] = 180 + (_targetAngles[i] - 180) * clockGroup.CurrentProgress.Value;
                }
                InvalidateVisual();
            }
        };

        _animationStoryboard.Children.Add(animation);
        _animationStoryboard.Begin(this, true);
    }

    protected override void OnRender(DrawingContext drawingContext)
    {
        base.OnRender(drawingContext);

        if (ItemsSource == null || !ItemsSource.Any()) return;

        var centerX = ActualWidth / 2;
        var centerY = ActualHeight * 0.4;
        var radius = Math.Min(centerX, centerY) * 0.8;
        var innerRadius = radius * 0.6;

        var items = ItemsSource.ToList();
        var prevAngle = 180.0;
        
        for (int i = 0; i < items.Count; i++)
        {
            var currentAngle = _isAnimating ? _currentAngles[i] : _targetAngles[i];
            var sweepAngle = currentAngle - prevAngle;
            
            var path = CreateArcGeometry(
                centerX, centerY,
                radius, innerRadius,
                prevAngle, sweepAngle);

            var brush = new SolidColorBrush(items[i].Color);
            drawingContext.DrawGeometry(brush, new Pen(Brushes.White, 1), path);
            
            prevAngle = currentAngle;
        }
    }

    private Geometry CreateArcGeometry(
        double centerX, double centerY,
        double radius, double innerRadius,
        double startAngle, double sweepAngle)
    {
        var startRadian = startAngle * Math.PI / 180;
        var endRadian = (startAngle + sweepAngle) * Math.PI / 180;

        var outerStart = new Point(
            centerX + radius * Math.Cos(startRadian),
            centerY + radius * Math.Sin(startRadian));
        var outerEnd = new Point(
            centerX + radius * Math.Cos(endRadian),
            centerY + radius * Math.Sin(endRadian));
        var innerStart = new Point(
            centerX + innerRadius * Math.Cos(startRadian),
            centerY + innerRadius * Math.Sin(startRadian));
        var innerEnd = new Point(
            centerX + innerRadius * Math.Cos(endRadian),
            centerY + innerRadius * Math.Sin(endRadian));

        var figure = new PathFigure { StartPoint = outerStart };
        figure.Segments.Add(new ArcSegment(
            outerEnd,
            new Size(radius, radius),
            0,
            false,
            SweepDirection.Counterclockwise,
            true));
        figure.Segments.Add(new LineSegment(innerEnd, true));
        figure.Segments.Add(new ArcSegment(
            innerStart,
            new Size(innerRadius, innerRadius),
            0,
            false,
            SweepDirection.Clockwise,
            true));
        figure.IsClosed = true;

        return new PathGeometry { Figures = { figure } };
    }
}
햄버거버튼
        <!-- 햄버거 메뉴 클릭 애니메이션 -->
        <Storyboard x:Key="MenuClickAnimation">
            <DoubleAnimation 
                Storyboard.TargetName="MenuIcon"
                Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)"
                From="0" To="180" Duration="0:0:0.3">
                <DoubleAnimation.EasingFunction>
                    <CubicEase EasingMode="EaseInOut"/>
                </DoubleAnimation.EasingFunction>
            </DoubleAnimation>
        </Storyboard>

        <!-- 햄버거 메뉴 마우스 오버 애니메이션 -->
        <Storyboard x:Key="MenuHoverAnimation">
            <DoubleAnimation 
                Storyboard.TargetName="MenuIcon"
                Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)"
                To="90" Duration="0:0:0.2">
                <DoubleAnimation.EasingFunction>
                    <CubicEase EasingMode="EaseInOut"/>
                </DoubleAnimation.EasingFunction>
            </DoubleAnimation>
        </Storyboard>

        <!-- 햄버거 메뉴 마우스 리브 애니메이션 -->
        <Storyboard x:Key="MenuLeaveAnimation">
            <DoubleAnimation 
                Storyboard.TargetName="MenuIcon"
                Storyboard.TargetProperty="(UIElement.RenderTransform).(RotateTransform.Angle)"
                To="0" Duration="0:0:0.2">
                <DoubleAnimation.EasingFunction>
                    <CubicEase EasingMode="EaseInOut"/>
                </DoubleAnimation.EasingFunction>
            </DoubleAnimation>
        </Storyboard>


                    <!-- 햄버거 메뉴 버튼 -->
                    <Button x:Name="MenuButton" 
                Grid.Column="0" 
                Width="40" Height="40" 
                Background="Transparent" 
                BorderThickness="0"
                Click="MenuButton_Click"
                MouseEnter="MenuButton_MouseEnter"
                MouseLeave="MenuButton_MouseLeave">
                        <Grid x:Name="MenuIcon" RenderTransformOrigin="0.5,0.5">
                            <Grid.RenderTransform>
                                <RotateTransform/>
                            </Grid.RenderTransform>
                            <Path Data="M4,6 L20,6 M4,12 L20,12 M4,18 L20,18"
                      Stroke="#333333"
                      StrokeThickness="2"
                      Width="24"
                      Height="24"
                      Stretch="None"
                      StrokeStartLineCap="Round"
                      StrokeEndLineCap="Round"/>
                        </Grid>
                    </Button>

        private void MenuButton_Click(object sender, RoutedEventArgs e)
        {
            var storyboard = (Storyboard)FindResource("MenuClickAnimation");

            if (_isMenuOpen)
            {
                storyboard.AutoReverse = true;
                storyboard.Begin();
            }
            else
            {
                storyboard.AutoReverse = false;
                storyboard.Begin();
            }

            _isMenuOpen = !_isMenuOpen;
        }

        private void MenuButton_MouseEnter(object sender, MouseEventArgs e)
        {
            if (!_isMenuOpen)
            {
                _isHovered = true;
                var storyboard = (Storyboard)FindResource("MenuHoverAnimation");
                storyboard.Begin();
                viewModel.ShowPanel();
            }
        }

        private void MenuButton_MouseLeave(object sender, MouseEventArgs e)
        {
            if (!_isMenuOpen && _isHovered)
            {
                _isHovered = false;
                var storyboard = (Storyboard)FindResource("MenuLeaveAnimation");
                storyboard.Begin();
            }
        }

        private void OnClickProfile(object sender, RoutedEventArgs e)
        {
            var logon = new LogOn();
            logon.Show();
        }

        private void SlidingPanel_MouseLeave(object sender, MouseEventArgs e)
        {
            // 마우스가 햄버거 버튼 위에 있는지 확인
            Point mousePos = e.GetPosition(MenuButton);
            if (mousePos.X < 0 || mousePos.Y < 0 ||
                mousePos.X > MenuButton.ActualWidth ||
                mousePos.Y > MenuButton.ActualHeight)
            {
                viewModel.HidePanel();
            }
        }
라운딩컴포넌트들
<TextBox Style="{StaticResource RoundedTextBoxStyle}">
   <TextBox.Resources>
       <Style x:Key="RoundedTextBoxStyle" TargetType="TextBox">
           <Setter Property="Template">
               <Setter.Value>
                   <ControlTemplate TargetType="TextBox">
                       <Border Background="White" 
                               BorderBrush="#E5E5E5"
                               BorderThickness="1"
                               CornerRadius="20">
                           <Grid>
                               <TextBox Text="{Binding Path=Text, 
                                        RelativeSource={RelativeSource TemplatedParent}, 
                                        UpdateSourceTrigger=PropertyChanged}"
                                        Background="Transparent"
                                        BorderThickness="0"
                                        Padding="20,10"
                                        VerticalContentAlignment="Center"/>
                               
                               <TextBlock Text="영역 이름을 작성해 주세요."
                                         Foreground="#999999" 
                                         Margin="20,0,0,0"
                                         VerticalAlignment="Center"
                                         Visibility="{Binding Path=Text.IsEmpty, 
                                         RelativeSource={RelativeSource TemplatedParent}, 
                                         Converter={StaticResource BooleanToVisibilityConverter}}"/>
                           </Grid>
                       </Border>
                   </ControlTemplate>
               </Setter.Value>
           </Setter>
       </Style>
   </TextBox.Resources>
</TextBox>

<ComboBox Text="Horizontal" Width="200" Height="40">
    <ComboBox.Resources>
        <Style TargetType="ComboBox">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="ComboBox">
                        <Grid>
                            <Border x:Name="border" 
                                    CornerRadius="8"
                                    Background="White" 
                                    BorderBrush="#E5E5E5"
                                    BorderThickness="1">
                                <Grid>
                                    <Grid.ColumnDefinitions>
                                        <ColumnDefinition/>
                                        <ColumnDefinition Width="30"/>
                                    </Grid.ColumnDefinitions>
                                    <ContentPresenter 
                                        Content="{TemplateBinding SelectionBoxItem}"
                                        ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
                                        Margin="12,0,0,0"
                                        VerticalAlignment="Center"/>
                                    <Path Grid.Column="1" 
                                          Data="M0,0 L5,5 L10,0" 
                                          Stroke="#666666"
                                          StrokeThickness="2"
                                          HorizontalAlignment="Center" 
                                          VerticalAlignment="Center"/>
                                </Grid>
                            </Border>
                            <Popup IsOpen="{TemplateBinding IsDropDownOpen}"
                                   Placement="Bottom"
                                   PlacementTarget="{Binding ElementName=border}">
                                <Border Background="White"
                                        BorderBrush="#E5E5E5"
                                        BorderThickness="1"
                                        CornerRadius="8"
                                        Margin="0,4,0,0">
                                    <ScrollViewer MaxHeight="200" VerticalScrollBarVisibility="Auto">
                                        <StackPanel IsItemsHost="True" Margin="0,4"/>
                                    </ScrollViewer>
                                </Border>
                            </Popup>
                        </Grid>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter TargetName="border" Property="BorderBrush" Value="#CCCCCC"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        
        <Style TargetType="ComboBoxItem">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="ComboBoxItem">
                        <Border Padding="12,8" Background="Transparent">
                            <ContentPresenter/>
                        </Border>
                        <ControlTemplate.Triggers>
                            <Trigger Property="IsMouseOver" Value="True">
                                <Setter Property="Background" Value="#F5F5F5"/>
                            </Trigger>
                            <Trigger Property="IsSelected" Value="True">
                                <Setter Property="Background" Value="#E3F2FD"/>
                            </Trigger>
                        </ControlTemplate.Triggers>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </ComboBox.Resources>
    <ComboBoxItem>Horizontal</ComboBoxItem>
    <ComboBoxItem>Vertical</ComboBoxItem>
    <ComboBoxItem>Diagonal</ComboBoxItem>
</ComboBox>

[Prism] 019. Prism의 테스트 가능성, 뷰모델 테스트 구현하기

WPF(Windows Presentation Foundation)는 데스크탑 응용 프로그램을 개발하기 위한 강력한 기술로, 사용자 인터페이스(UI)와 비즈니스 로직을 분리하여 코드의 재사용성과 유지보수성을 높입니다. Prism은 WPF 애플리케이션의 구조와 패턴을 개선하는 데 중점을 두고 있는 프레임워크이며, MVVM(Model-View-ViewModel) 아키텍처를 채택하여 애플리케이션의 테스트 가능성을 극대화합니다. 본 글에서는 Prism 프레임워크에서 뷰모델을 어떻게 테스트할 수 있는지에 대해 자세히 다뤄보겠습니다.

Prism 프레임워크와 MVVM 패턴

Prism은 여러 핵심 패턴과 원칙을 제공하여 WPF 애플리케이션의 구조를 정리합니다. MVVM 패턴은 이러한 패턴 중 하나로, UI와 비즈니스 로직을 효과적으로 분리하여 각각의 독립적인 테스트를 가능하게 합니다. MVVM에서 ViewModel은 View와 Model 사이에서 데이터 바인딩을 담당하며, UI와 비즈니스 로직 사이의 상호작용을 관리합니다.

테스트 가능성의 중요성

신뢰성 높은 소프트웨어를 개발하기 위해서는 단위 테스트가 필수적입니다. Prism을 사용하면 뷰모델을 독립적으로 테스트할 수 있기 때문에, UI와의 상호작용을 신경 쓰지 않고도 비즈니스 로직을 검증할 수 있습니다. 이는 코드의 품질을 높이고, 유지 보수 비용을 줄이며, 개발 주기를 단축하는 데 기여합니다.

뷰모델 테스트 구현하기

이제 Prism을 사용하여 뷰모델을 어떻게 테스트하는지에 대해 구체적인 예제를 통해 알아보겠습니다. 다음은 간단한 Todo List 애플리케이션의 뷰모델을 테스트하는 방법입니다.

1. TodoItem 클래스 정의


public class TodoItem
{
    public string Title { get; set; }
    public bool IsCompleted { get; set; }
}
    

2. TodoViewModel 클래스 정의


using Prism.Commands;
using Prism.Mvvm;
using System.Collections.ObjectModel;

public class TodoViewModel : BindableBase
{
    private string _newTodoTitle;
    public string NewTodoTitle
    {
        get { return _newTodoTitle; }
        set { SetProperty(ref _newTodoTitle, value); }
    }

    public ObservableCollection TodoItems { get; private set; }
    public DelegateCommand AddTodoCommand { get; private set; }

    public TodoViewModel()
    {
        TodoItems = new ObservableCollection();
        AddTodoCommand = new DelegateCommand(AddTodo, CanAddTodo).ObservesProperty(() => NewTodoTitle);
    }

    private void AddTodo()
    {
        if (!string.IsNullOrWhiteSpace(NewTodoTitle))
        {
            TodoItems.Add(new TodoItem { Title = NewTodoTitle });
            NewTodoTitle = string.Empty;
        }
    }

    private bool CanAddTodo()
    {
        return !string.IsNullOrWhiteSpace(NewTodoTitle);
    }
}
    

3. 뷰모델 테스트 구현

이제 커뮤니티에서 널리 사용되는 NUnit과 Moq를 사용하여 위에서 정의한 TodoViewModel 클래스를 테스트해보겠습니다.


using NUnit.Framework;
using Prism.Commands;
using System.Collections.ObjectModel;

[TestFixture]
public class TodoViewModelTests
{
    private TodoViewModel _viewModel;

    [SetUp]
    public void SetUp()
    {
        _viewModel = new TodoViewModel();
    }

    [Test]
    public void AddTodo_ShouldAddTodoItem()
    {
        // Arrange
        _viewModel.NewTodoTitle = "Test Todo";

        // Act
        _viewModel.AddTodoCommand.Execute();

        // Assert
        Assert.AreEqual(1, _viewModel.TodoItems.Count);
        Assert.AreEqual("Test Todo", _viewModel.TodoItems[0].Title);
    }

    [Test]
    public void AddTodo_WhenTitleIsEmpty_ShouldNotAddTodoItem()
    {
        // Arrange
        _viewModel.NewTodoTitle = string.Empty;

        // Act
        _viewModel.AddTodoCommand.Execute();

        // Assert
        Assert.AreEqual(0, _viewModel.TodoItems.Count);
    }

    [Test]
    public void CanAddTodo_WhenTitleIsNotEmpty_ShouldReturnTrue()
    {
        // Arrange
        _viewModel.NewTodoTitle = "Test Todo";

        // Act
        var canAdd = _viewModel.AddTodoCommand.CanExecute();

        // Assert
        Assert.IsTrue(canAdd);
    }

    [Test]
    public void CanAddTodo_WhenTitleIsEmpty_ShouldReturnFalse()
    {
        // Arrange
        _viewModel.NewTodoTitle = string.Empty;

        // Act
        var canAdd = _viewModel.AddTodoCommand.CanExecute();

        // Assert
        Assert.IsFalse(canAdd);
    }
}
    

테스트 실행 및 결과 확인

작성한 테스트 코드를 실행하고 결과를 확인합니다. NUnit에서는 Visual Studio에서 통합된 테스트 기능을 제공하므로 테스트 탐색기를 통해 결과를 쉽게 확인할 수 있습니다. 모든 테스트가 성공하면 뷰모델이 정확하게 동작하고 있음을 의미합니다.

정리

Prism 프레임워크를 사용하면 WPF 애플리케이션의 뷰모델을 효율적으로 테스트할 수 있습니다. MVVM 패턴을 통해 UI와 비즈니스 로직을 분리하고, 다양한 단위 테스트를 통해 코드의 신뢰성을 높일 수 있습니다. 이로 인해 안정적이고 유지 보수가 용이한 애플리케이션을 구축하는 데 큰 도움을 받을 수 있습니다.

[Prism] 005. Prism 설치 및 초기 설정, Visual Studio에서의 기본 프로젝트 설정

005. Prism 설치 및 초기 설정, Visual Studio에서의 기본 프로젝트 설정

WPF(Windows Presentation Foundation) 애플리케이션 개발에 있어 Prism 프레임워크는 매우 유용한 도구입니다. Prism은 모듈화, MVVM(모델-뷰-뷰모델) 패턴, 의존성 주입 등과 같은 고급 애플리케이션 디자인 패턴을 지원하여 개발자가 복잡한 애플리케이션을 효율적으로 구축할 수 있도록 도와줍니다. 이번 글에서는 Prism을 설치하고 초기 설정을 수행한 후, Visual Studio에서 기본 프로젝트를 설정하는 과정을 자세히 설명하겠습니다.

1. Prism 설치

Prism은 NuGet 패키지로 쉽게 설치할 수 있습니다. Visual Studio에서 Prism을 설치하려면 다음 단계를 따르세요.

  1. Visual Studio를 열고, 새 WPF 애플리케이션 프로젝트를 생성합니다. 2022년 이상의 Visual Studio 버전을 사용하는 것이 좋습니다. 프로젝트 템플릿을 선택할 때 .NET Core 혹은 .NET 5/6/7를 기반으로 하는 WPF 애플리케이션을 선택합니다.

  2. 프로젝트가 생성되면, 도구 > NuGet 패키지 관리자 > 패키지 관리자 콘솔을 선택합니다.

  3. 패키지 관리자 콘솔에 다음 명령을 입력하여 Prism 패키지를 설치합니다:

    Install-Package Prism.Unity

    이 명령은 Prism의 Unity 의존성 주입 컨테이너를 설치합니다. 다른 DI 컨테이너를 사용하고 싶다면 적절한 패키지를 선택하여 설치할 수 있습니다.

2. 초기 설정

Prism을 설치한 후, 몇 가지 초기 설정을 수행하여 애플리케이션 구조를 정의해야 합니다.

  1. App.xaml 파일을 열고, Application 태그의 StartupUri 속성을 제거합니다. 우리는 Prism의 Bootstrapper를 사용할 것이기 때문에 기본 설정을 변경할 필요가 있습니다.

    <Application x:Class="MyPrismApp.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" />
  2. App.xaml.cs 파일을 열고, Prism의 PrismApplication 클래스를 상속받은 새로운 부트스트래퍼 클래스를 생성합니다:

    public partial class App : PrismApplication
    {
        protected override void RegisterTypes(IContainerRegistry containerRegistry)
        {
            // 서비스 등록
        }
    
        protected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog)
        {
            // 모듈 등록
        }
    
        protected override void OnInitialized()
        {
            base.OnInitialized();
            NavigationService.NavigateAsync("MainPage");
        }
    }

3. 기본 프로젝트 설정

다음으로는 프로젝트의 기본 구조를 설정하고, 필요한 모듈 및 페이지를 생성하는 과정을 살펴보겠습니다.

  1. 주 페이지가 될 MainPage.xaml을 생성합니다. 솔루션 탐색기에서 마우스 오른쪽 버튼 클릭 후, 추가 > 새 항목을 선택해 WPF 페이지를 추가합니다.

    <Page x:Class="MyPrismApp.Views.MainPage"
                      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                      Title="MainPage">
        <Grid>
            <TextBlock Text="Hello, Prism!" FontSize="36" HorizontalAlignment="Center" VerticalAlignment="Center"/>
        </Grid>
    </Page>
  2. MVVM 패턴을 적용하기 위해 MainPageViewModel 클래스를 생성합니다. 이 클래스는 페이지의 로직과 데이터 바인딩을 담당하게 됩니다:

    public class MainPageViewModel : BindableBase
    {
        private string _title;
        public string Title
        {
            get { return _title; }
            set { SetProperty(ref _title, value); }
        }
    
        public MainPageViewModel()
        {
            Title = "Welcome to Prism!";
        }
    }
  3. ViewModel과 View를 연결하기 위해, XAML 코드에서 DataContext를 설정합니다:

    <Page.DataContext>
        <local:MainPageViewModel />
    </Page.DataContext>
  4. 마지막으로, Prism의 모듈 시스템을 활용하기 위해, 새로운 프로젝트를 추가하여 모듈을 구성합니다. 이 경우, 새로운 Class Library 프로젝트를 추가하고 Prism 모듈을 구현할 수 있습니다.

    public class MyModule : IModule
    {
        private readonly IContainerProvider _containerProvider;
    
        public MyModule(IContainerProvider containerProvider)
        {
            _containerProvider = containerProvider;
        }
    
        public void OnInitialized()
        {
            // 초기화 과정
        }
    
        public void RegisterTypes(IContainerRegistry containerRegistry)
        {
            // 타입 등록
        }
    }

4. 결론

이제 Prism을 설치하고 초기 설정을 수행하여 Visual Studio에서 기본 프로젝트를 설정하는 방법을 알아보았습니다. Prism은 WPF 애플리케이션 개발시 모듈화되고 유지보수하기 쉬운 구조를 제공하므로, 큰 애플리케이션을 개발할 때 강력한 도구가 됩니다. 다음 글에서는 Prism의 더 많은 기능과 활용 사례에 대해 다루어 보겠습니다.

Prism을 통해 WPF 애플리케이션의 복잡성을 줄이고, 더 나은 구조와 유지보수성을 가진 애플리케이션을 만들어 보세요.

[Prism] 012. Prism에서의 내비게이션 구현, 페이지 간 이동 및 매개변수 전달

WPF(Windows Presentation Foundation)에서 Prism 프레임워크를 사용하여 애플리케이션을 개발할 때, 내비게이션은 매우 중요한 기능입니다. 이 글에서는 Prism에서 내비게이션을 구현하는 방법, 페이지 간 이동, 그리고 매개변수를 전달하는 방법을 상세히 설명하겠습니다.

1. Prism 프레임워크 개요

Prism은 WPF 애플리케이션을 모듈식으로 개발할 수 있게 해주는 강력한 프레임워크입니다. MVVM(모델-뷰-뷰모델) 패턴을 지지하며, 의존성 주입, 이벤트 발행, 그리고 내비게이션 기능을 제공합니다. Prism의 강력한 내비게이션 기능 덕분에 우리는 다양한 페이지 간에 쉽고 안전하게 전환할 수 있습니다.

2. Prism 내비게이션의 구성 요소

2.1 INavigationService

Prism의 INavigationService 인터페이스는 내비게이션 작업을 수행하는 주요 인터페이스입니다. 이 서비스는 다음과 같은 중요한 메서드를 제공합니다:

  • NavigateAsync(string uri): URI를 기반으로 새 페이지로 이동합니다.
  • GoBackAsync(): 이전 페이지로 돌아갑니다.
  • GoForwardAsync(): 다음 페이지로 이동합니다.
  • RequestNavigate(NavigationParameters parameters): 매개변수를 포함하여 페이지를 내비게이션합니다.

2.2 NavigationParameters

NavigationParameters 클래스는 페이지 간에 데이터를 전달할 수 있는 기능을 제공합니다. 이는 주로 NavigateAsync 메서드 호출 시 전달되며, 페이지 간의 데이터 전송을 용이하게 해줍니다.

3. Prism 내비게이션 설정하기

3.1 PrismApplication 설정

Prism 애플리케이션을 시작하기 위해, PrismApplication 클래스를 상속받은 클래스를 생성합니다.


public class App : PrismApplication
{
    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        // 서비스 등록
        containerRegistry.RegisterForNavigation();
        containerRegistry.RegisterForNavigation();
    }

    protected override void OnInitialized()
    {
        InitializeComponent();
        NavigationService.NavigateAsync("MainPage");
    }
}

3.2 페이지 구성

이제 두 개의 페이지 MainPageSecondPage를 생성하고, 이들 간의 내비게이션을 설정해 보겠습니다.


public partial class MainPage : ContentPage
{
    public MainPage(IRegionManager regionManager)
    {
        InitializeComponent();
        NavigateToSecondPage(regionManager);
    }

    private async void NavigateToSecondPage(IRegionManager regionManager)
    {
        var parameters = new NavigationParameters();
        parameters.Add("Message", "Hello from MainPage!");
        await regionManager.RequestNavigate("ContentRegion", "SecondPage", parameters);
    }
}

4. 페이지 간 이동하기

위의 코드에서 RequestNavigate 메서드를 통해 MainPage에서 SecondPage로 이동하며, 매개변수(“Message”)를 전달합니다.


public partial class SecondPage : ContentPage, INavigatedAware
{
    public void OnNavigatedTo(NavigationContext navigationContext)
    {
        if (navigationContext.Parameters.ContainsKey("Message"))
        {
            var message = navigationContext.Parameters.GetValue("Message");
            DisplayAlert("Message", message, "OK");
        }
    }

    public bool IsNavigationTarget(NavigationContext navigationContext)
    {
        return true;
    }

    public void OnNavigatedFrom(NavigationContext navigationContext) { }
}

5. 매개변수 전달 및 사용하기

SecondPageOnNavigatedTo 메서드에서 전달된 매개변수를 수신하고, 메시지를 사용자에게 알립니다. 여기서 INavigatedAware 인터페이스는 페이지가 내비게이션에 어떻게 반응할지를 정의하는데 필요합니다.

5.1 매개변수 추가하기

매개변수를 추가하는 것은 매우 간단합니다. 페이지로 이동하기 전에 NavigationParameters 객체에 필요한 키와 값을 추가하기만 하면 됩니다.


private void NavigateToSecondPage(IRegionManager regionManager)
{
    var parameters = new NavigationParameters();
    parameters.Add("Message", "Hello from MainPage!");
    parameters.Add("UserID", 1234);
    await regionManager.RequestNavigate("ContentRegion", "SecondPage", parameters);
}

5.2 다중 매개변수 수신하기

다중 매개변수를 수신하려면 OnNavigatedTo 메서드에서 각 키를 확인하고 처리하면 됩니다.


public void OnNavigatedTo(NavigationContext navigationContext)
{
    if (navigationContext.Parameters.ContainsKey("Message") && navigationContext.Parameters.ContainsKey("UserID"))
    {
        var message = navigationContext.Parameters.GetValue("Message");
        var userId = navigationContext.Parameters.GetValue("UserID");
        DisplayAlert("User ID", $"Message: {message}, UserID: {userId}", "OK");
    }
}

6. 내비게이션 히스토리 관리

Prism은 내비게이션 히스토리를 자동으로 관리하여 사용자에게 ‘뒤로’ 및 ‘앞으로’ 기능을 제공합니다. INavigationService를 사용하여 이러한 기능을 쉽게 구현할 수 있습니다.


private async void GoBack()
{
    if (NavigationService.CanGoBack)
    {
        await NavigationService.GoBackAsync();
    }
}

7. 사용자 경험 개선하기

비동기 내비게이션 메서드 호출 시, 사용자의 인터페이스가 불편해지지 않도록 고민해야 합니다. 예를 들어, 내비게이션 중 로딩 스피너를 표시하거나 비활성화된 상태를 유지할 수 있습니다.


private async void NavigateToSecondPage(IRegionManager regionManager)
{
    IsBusy = true; // 로딩 시작
    var parameters = new NavigationParameters();
    parameters.Add("Message", "Hello from MainPage!");
    await regionManager.RequestNavigate("ContentRegion", "SecondPage", parameters);
    IsBusy = false; // 로딩 종료
}

8. 결론

Prism에서 내비게이션을 구현하는 과정은 간단하면서도 강력한 사용자 경험을 제공합니다. 페이지 간에 매개변수를 전달하고, 내비게이션 상태를 관리하는 기능을 통해 애플리케이션의 유연성을 크게 향상시킬 수 있습니다.

이 글에서 다룬 내비게이션의 기초 개념을 바탕으로, 다양한 UI 구조를 가진 복잡한 애플리케이션을 설계하는 데 있어 Prism의 내비게이션 기능을 활용해보시기를 바랍니다.

이 글이 WPF와 Prism을 사용하여 내비게이션을 구현하는 데 도움이 되었기를 바랍니다. 여러분의 애플리케이션 개발에 성공을 기원합니다!

[Prism] 021. 고급 기능 및 활용 사례, Prism의 RegionManager 사용법

WPF(Windows Presentation Foundation) 애플리케이션을 개발할 때, 복잡한 UI 구조와 관리되는 상태를 쉽게 처리하기 위해 Prism 프레임워크를 사용하는 것이 매우 유용합니다. Prism은 MVVM(Model-View-ViewModel) 패턴을 기반으로 한 애플리케이션 아키텍처를 제공하며, 이 중 RegionManager는 UI의 구성 요소를 동적으로 관리하는 데 핵심적인 역할을 합니다.

1. Prism의 RegionManager란?

RegionManager는 Prism의 핵심 구성 요소로, 애플리케이션의 다양한 관점에서 UI를 정의하고 조작할 수 있게 해줍니다. RegionManager를 사용하면 여러 UI 요소를 관리하고 동적으로 콘텐츠를 로드하거나 갱신할 수 있습니다. 일반적으로 Navigation과 Dynamic Content Loading을 용이하게 하는 데 사용됩니다.

2. Region 및 RegionManager 개념 이해하기

Region은 UI 내에서 콘텐츠를 호스팅할 수 있는 가상의 컨테이너입니다. Region은 사용자 Control, Windows, UserControl 등 다양한 형태로 구현할 수 있습니다. RegionManager는 이러한 Region을 관리하고, 다양한 View를 해당 Region에 동적으로 추가하거나 교체할 수 있는 기능을 제공합니다.

2.1 Region 설정하기

Region을 설정하기 위해서는 우선 XAML에서 Region을 선언해야 하며, 이에 따라 주로 RegionManager를 통해 View를 로드합니다.

<ContentControl prism:RegionManager.RegionName="MainRegion" />

위의 예제에서 ContentControl은 Region의 역할을 하며, “MainRegion”이라는 이름으로 RegionManager에 등록됩니다.

2.2 RegionManager 등록하기

RegionManager를 사용하기 위해서는 먼저 Prism 라이브러리를 프로젝트에 추가해야 합니다. 다음으로, IContainerRegistry를 사용하여 RegionManager를 등록합니다.

protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
    containerRegistry.RegisterForNavigation<MainView>();
    containerRegistry.RegisterForNavigation<SecondaryView>();
    RegionManager.SetRegionName(this.MainRegion, "MainRegion");
}

3. RegionManager 사용 예제

이번 섹션에서는 간단한 예제를 통해 RegionManager를 사용하는 방법을 살펴보겠습니다. 예제에서는 두 개의 View를 등록하고, 버튼 클릭 시 해당 View를 Region에 동적으로 추가하는 방법을 보여줍니다.

3.1 View 생성

먼저 두 개의 View를 생성합니다. MainView.xamlSecondaryView.xaml입니다.

<UserControl x:Class="MyApp.Views.MainView" ... >
    <Grid>
        <Button Content="Load Secondary View" Command="{Binding LoadSecondaryViewCommand}" />
    </Grid>
</UserControl>
<UserControl x:Class="MyApp.Views.SecondaryView" ... >
    <Grid Background="LightBlue">
        <TextBlock Text="This is the Secondary View" FontSize="24" />
    </Grid>
</UserControl>

3.2 ViewModel 생성

ViewModel을 생성하여 버튼 클릭 시 SecondaryView를 Region에 추가하는 로직을 구현합니다.

public class MainViewModel : BindableBase
{
    private readonly IRegionManager _regionManager;

    public MainViewModel(IRegionManager regionManager)
    {
        _regionManager = regionManager;
        LoadSecondaryViewCommand = new DelegateCommand(LoadSecondaryView);
    }

    public DelegateCommand LoadSecondaryViewCommand { get; private set; }

    private void LoadSecondaryView()
    {
        _regionManager.RequestNavigate("MainRegion", "SecondaryView");
    }
}

3.3 App.xaml.cs에서 Region 설정하기

App.xaml.cs 파일에서 개체를 등록하고 사용하는 방법은 다음과 같습니다.

protected override void ConfigureContainer()
{
    base.ConfigureContainer();
    Container.RegisterType<IRegionManager, RegionManager>();
}

3.4 XAML에서 메인 뷰 설정하기

메인 뷰를 XAML에서 설정하면 다음과 같습니다.

<Window x:Class="MyApp.MainWindow" ...>
    <Grid>
        <ContentControl prism:RegionManager.RegionName="MainRegion" />
    </Grid>
</Window>

4. 고급 RegionManager 사용법

이렇게 RegionManager를 통해 View를 설정하고 전환하는 기본적인 사용법을 살펴보았으므로, 이제 좀 더 고급로운 기능으로 넘어가 보겠습니다.

4.1 Region에 다중 View 추가하기

Prism에서는 하나의 Region에 여러 개의 View를 동적으로 추가할 수 있습니다. 이때는 다중 View를 설정하고 표시하는 방법을 사용할 수 있습니다. 다음은 예제 코드입니다.

private void AddMultipleViews()
{
    for (int i = 0; i < 5; i++)
    {
        string viewName = $"View{i}";
        _regionManager.RequestNavigate("MainRegion", viewName);
    }
}

4.2 Region의 Lifecycle 관리하기

RegionManager는 각 Region의 Lifecycle을 관리할 수 있는 기능도 제공합니다. Region이 생성, 활성화 및 비활성화될 때 이벤트를 수신할 수 있습니다. 다음은 Region의 Lifecycle을 관리하는 방법입니다.

private void OnRegionActiveChanged(object sender, RegionEventArgs e)
{
    // Region 활성화 시 처리 작업
}

private void RegionViewLoaded(object sender, RegionEventArgs e)
{
    // View가 로드될 때 처리 작업
}

5. 활용 사례

RegionManager를 활용하면 다수의 View를 유기적으로 조합해 사용하는 애플리케이션을 쉽게 구축할 수 있습니다. 예를 들어, TabControl을 사용한 Multi-tab UI 구현, 또는 특정 이벤트에 따라 UI를 변경하는 데 매우 유용합니다.

5.1 TabControl을 사용한 Multi-tab UI

TabControl을 사용하여 각 Tab이 Region이 되어 서로 다른 View를 표시하도록 구성할 수 있습니다. 다음은 TabControl을 설정하는 예제입니다.

<TabControl prism:RegionManager.RegionName="MainTabRegion">
    <TabItem Header="Tab 1">
        <ContentControl prism:RegionManager.RegionName="Tab1Region" />
    </TabItem>
    <TabItem Header="Tab 2">
        <ContentControl prism:RegionManager.RegionName="Tab2Region" />
    </TabItem>
</TabControl>

5.2 이벤트 기반 UI 변경

특정 이벤트를 기반으로 UI를 동적으로 변경하는 경우에도 RegionManager를 유용하게 활용할 수 있습니다. 예를 들어, 사용자의 로그인 상태에 따라 보여줘야 할 UI를 변경할 수 있습니다. 아래는 간단한 로직 예제입니다.

private void OnUserLoginStatusChanged(bool isLoggedIn)
{
    string targetView = isLoggedIn ? "HomeView" : "LoginView";
    _regionManager.RequestNavigate("MainRegion", targetView);
}

6. 결론

Prism의 RegionManager는 WPF 애플리케이션에서 UI를 동적으로 관리하고 표시하는 데 필수적인 도구입니다. 이 프레임워크를 통해 개발자는 복잡한 UI 요구 사항을 쉽게 충족시킬 수 있으며, View의 라이프사이클 및 네비게이션을 유연하게 처리할 수 있습니다. 지난 섹션에서 살펴본 고급 기능은 사용자가 매력적이고 반응성이 뛰어난 애플리케이션을 만드는 데 도움을 주며, 다양한 활용 사례를 통해 어떻게 접근할 수 있는지를 보여주었습니다. 앞으로 Prism을 활용하여 더욱 발전된 소프트웨어 솔루션을 만들어 보시기 바랍니다.