WFP MVVM 가로 스크롤 구현

안녕하세요, WPF 초보입니다.
검색에 검색을 하다 여기까지 흘러 들어왔습니다. 물론 다른 내용으로 검색하다 흘러 들어 왔지만, 내용이 알차여 몇일을 정작하였습니다.

웹 BE가 베이스라 wpf 가 마냥 쉽지많은 않네요


desktop 앱으로 라인 이미지를 가로로 쌓아야 하는 경우가 있는데, 이때 확대시 위아래 좌우 스크롤이 되어야 하는 상황입니다.

위아래 스크롤은 xaml 에서 ScrollVeiwer 로 간단하게 구현이 되는데, 가로 스크롤이 구현이 안되더라구요
(일반적으로 shift + 휠 조작으로 가능하던데, 이것도 구현해야 할 줄은 생각못했습니다)

그래서 하루종일 서치하여 결국 해결 하기는 했는데 이게 맞는 방법 인지 모르겠습니다.

MVVM 패턴을 이용 하고 있고, 코드 비하인드에서 직접 UI를 조작 하는 방식으로 구현이 되어버려서 MVVM 패턴에 위배되는거 같은데 다른 옵션을 찾아보아도 도통 보이질 않네요
**CommunityToolkit.MVVM 라이브러리 사용중에 있습니다.

다른 옵션이 있으면 알려주실수 있을까 하여 질문 남깁니다.


public partial class CustomerPage : Page
{
    public CustomerPage()
    {
        InitializeComponent();
        DataContext = App.Current.Services.GetService(typeof(CustomerViewModel));
        
        WeakReferenceMessenger.Default.Register<HorizontalScrollMessage>(this, (r, m) =>
        {
            if (m.ScrollLeft)
            {
                scrollViewer.LineLeft();
            }
            else
            {
                scrollViewer.LineRight();
            }
        });
        
    }
}

이렇게 코드 비하인드에서 직접 UI를 조작 하고, ViewModel 에서는 메세징으로 전달하는 방법입니다.

   // 가로 스크롤처리
    private void OnPreviewMouseWheel(MouseWheelEventArgs e)
    {
        ArgumentNullException.ThrowIfNull(e);
        // 지금 키보드가 눌러 져있는지 확인 
        if (Keyboard.Modifiers == ModifierKeys.Shift)
        {
            switch (e.Delta)
            {
                // 아래로 굴리면 델타는 줄어들고
                // 위로 굴리면 델타는 늘어난다
                // Scroll left
                case > 0:
                    WeakReferenceMessenger.Default.Send(new HorizontalScrollMessage { ScrollLeft = true });
                    break;
                // Scroll right
                case < 0:
                    WeakReferenceMessenger.Default.Send(new HorizontalScrollMessage { ScrollLeft = false });
                    break;
            }

            // 잡히면 아무 동작도 하지 않는다.
            e.Handled = true;
        }
    }

위에 보시는 코드는 , ViewModel 에서 Command 처리를 하는 부분이구요

  <ScrollViewer x:Name="scrollViewer" 
                      Grid.Row="2"
                      VerticalScrollBarVisibility="Auto"
                      HorizontalScrollBarVisibility="Auto">
            <b:Interaction.Triggers>
                <b:EventTrigger EventName="PreviewMouseWheel">
                    <common:EventToCommand Command="{Binding PreviewMouseWheelCommand}" />
                </b:EventTrigger>
            </b:Interaction.Triggers>

위에 보시는 코드는 xaml 코드입니다.
CommunityToolkit.MVVM 에서는 이벤트를 커멘드와 같이 보내는게 어려워, copilot 의 도움을 받아 EventToCommand 를 정의 하였습니다.

namespace HSIClient.Common;

using System.Windows;
using System.Windows.Input;
using Microsoft.Xaml.Behaviors;

public class EventToCommand : TriggerAction<DependencyObject>
{
    public static readonly DependencyProperty CommandProperty = DependencyProperty.Register(
        "Command", typeof(ICommand), typeof(EventToCommand), new PropertyMetadata(null));

    public static readonly DependencyProperty CommandParameterProperty = DependencyProperty.Register(
        "CommandParameter", typeof(object), typeof(EventToCommand), new PropertyMetadata(null));

    public ICommand Command
    {
        get { return (ICommand)GetValue(CommandProperty); }
        set { SetValue(CommandProperty, value); }
    }

    public object CommandParameter
    {
        get { return GetValue(CommandParameterProperty); }
        set { SetValue(CommandParameterProperty, value); }
    }

    protected override void Invoke(object parameter)
    {
        Command?.Execute(CommandParameter ?? parameter);
    }
}

위의 코드는 EventToCommand 입니다

코드 비하인드에서 직접 조작하는 방법이 아닌 다른 방법이 있을까요 ?

고민을 같이 읽어주셔서 감사합니다.

1개의 좋아요

비지니스 로직과 연결되지 않는 간단한 컨트롤의 기능을 확장하는데까지 MVVM을 고집할 필요는 없을 것 같습니다.

뷰 코드에서 한 두줄이면 구현 가능한 것을 MVVM 추구로 인해 불필요한 복잡성을 초래하기도 합니다. (말씀대로 현재의 코드도 제대로 된 MVVM이라고 보기는 어렵죠ㅎ)

일반적으로 아래 링크와 같이 Attatched Property나 Behavior 형식으로 만들어서 코드 분리와 재사용성을 추구하기도 합니다.

ChatGPT

Attached Property

먼저, ScrollViewer에 가로 스크롤 기능을 추가할 Attached Property를 정의합니다.

using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace YourNamespace
{
    public static class HorizontalScrollOnShiftMouseWheel
    {
        public static readonly DependencyProperty IsEnabledProperty =
            DependencyProperty.RegisterAttached(
                "IsEnabled",
                typeof(bool),
                typeof(HorizontalScrollOnShiftMouseWheel),
                new PropertyMetadata(false, OnIsEnabledChanged));

        public static bool GetIsEnabled(DependencyObject obj)
        {
            return (bool)obj.GetValue(IsEnabledProperty);
        }

        public static void SetIsEnabled(DependencyObject obj, bool value)
        {
            obj.SetValue(IsEnabledProperty, value);
        }

        private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (d is ScrollViewer scrollViewer)
            {
                if ((bool)e.NewValue)
                {
                    scrollViewer.PreviewMouseWheel += ScrollViewer_PreviewMouseWheel;
                }
                else
                {
                    scrollViewer.PreviewMouseWheel -= ScrollViewer_PreviewMouseWheel;
                }
            }
        }

        private static void ScrollViewer_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
        {
            if (Keyboard.Modifiers == ModifierKeys.Shift)
            {
                var scrollViewer = (ScrollViewer)sender;
                double newHorizontalOffset = scrollViewer.HorizontalOffset - e.Delta;
                scrollViewer.ScrollToHorizontalOffset(newHorizontalOffset);
                e.Handled = true;
            }
        }
    }
}

이 코드는 ScrollViewerIsEnabled라는 Attached Property를 추가하고, Shift 키가 눌린 상태에서 마우스 휠 이벤트를 가로 스크롤로 변경하는 기능을 제공합니다.

Behavior

다음으로, 같은 기능을 Behavior로 구현합니다. 이를 위해서는 Microsoft.Xaml.Behaviors.Wpf NuGet 패키지를 설치해야 합니다.

using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Microsoft.Xaml.Behaviors;

namespace YourNamespace
{
    public class HorizontalScrollBehavior : Behavior<ScrollViewer>
    {
        protected override void OnAttached()
        {
            base.OnAttached();
            AssociatedObject.PreviewMouseWheel += OnPreviewMouseWheel;
        }

        protected override void OnDetaching()
        {
            base.OnDetaching();
            AssociatedObject.PreviewMouseWheel -= OnPreviewMouseWheel;
        }

        private void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
        {
            if (Keyboard.Modifiers == ModifierKeys.Shift)
            {
                var scrollViewer = AssociatedObject;
                double newHorizontalOffset = scrollViewer.HorizontalOffset - e.Delta;
                scrollViewer.ScrollToHorizontalOffset(newHorizontalOffset);
                e.Handled = true;
            }
        }
    }
}

이 코드는 Behavior를 통해 동일한 기능을 제공하며, XAML에서 쉽게 사용할 수 있습니다.

사용 방법

Attached Property

<ScrollViewer local:HorizontalScrollOnShiftMouseWheel.IsEnabled="True">
    <!-- Your content here -->
</ScrollViewer>

Behavior

<Window x:Class="YourNamespace.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:YourNamespace"
        xmlns:i="http://schemas.microsoft.com/xaml/behaviors">
    <Grid>
        <ScrollViewer>
            <i:Interaction.Behaviors>
                <local:HorizontalScrollBehavior />
            </i:Interaction.Behaviors>
            <!-- Your content here -->
        </ScrollViewer>
    </Grid>
</Window>

이와 같이 Attached PropertyBehavior를 정의하고 사용하는 방법을 통해, ScrollViewer에 Shift 키와 마우스 휠을 이용한 가로 스크롤 기능을 쉽게 추가할 수 있습니다.

6개의 좋아요

감사합니다.

Behavior을 사용 중이었는데… 이런 기능인줄 모르고 있었네요

맨땅에 헤딩중이라 아직 공부가 부족하네요 다시한번 생각해보는 계기가 되었습니다.

개인적으로, 구현해주신 HorizontalScrollBehavior 은 가로 스크롤이동이 휠 한칸에 많은 거리를 이동 하는거 같아 아래와 같이 바꾸어 보았습니다.

public class HorizontalScrollBehavior : Behavior<ScrollViewer>
{
    protected override void OnAttached()
    {
        base.OnAttached();
        AssociatedObject.PreviewMouseWheel += OnPreviewMouseWheel;
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();
        AssociatedObject.PreviewMouseWheel -= OnPreviewMouseWheel;
    }

    private void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
    {
        if (Keyboard.Modifiers == ModifierKeys.Shift)
        {
            var scrollViewer = AssociatedObject;
            
            switch (e.Delta)
            {
            // 아래로 굴리면 델타는 줄어들고
            // 위로 굴리면 델타는 늘어난다
            case > 0:
            scrollViewer.LineLeft();
            break;
            // Scroll right
            case < 0:
            scrollViewer.LineRight();
            break;
        }
            // double newHorizontalOffset = scrollViewer.HorizontalOffset - e.Delta;
            // scrollViewer.ScrollToHorizontalOffset(newHorizontalOffset);
            e.Handled = true;
        }
    }
}

주석 처리된 부분이 알려주신 부분 이었는데, 이동 거리감이 왜 다른지 확인 해 봐야겠네요

댓글 주셔서 감사합니다 !

2개의 좋아요

이동 거리감이 다른 이유는 e.Delta값이 +/-120으로 들어오기 때문인데요, 휠 조작에 의한 ScrollViewer의 세로 스크롤 값은 48로 구현되어 있습니다.

보시면 Line 단위는 16, Wheel 단위는 48입니다.

따라서 LineLeft(), LineRight() 메소드를 호출할 경우 16씩 이동하기때문에 세로 휠 스크롤보다는 적게 이동하게 됩니다.

세로 휠 스크롤과 완벽하게 동일한 동작을 원하시면 리플렉션으로 ScrollInfo 속성을 가져와서 MouseWheelLeft(), MouseWheelRight() 메소드를 호출해 주시면 됩니다.

public class HorizontalScrollBehavior : Behavior<ScrollViewer>
{
    private static PropertyInfo _scrollInfoProperty =
        typeof(ScrollViewer).GetProperty("ScrollInfo", BindingFlags.NonPublic | BindingFlags.Instance);

    protected override void OnAttached()
    {
        base.OnAttached();
        AssociatedObject.PreviewMouseWheel += OnPreviewMouseWheel;
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();
        AssociatedObject.PreviewMouseWheel -= OnPreviewMouseWheel;
    }

    private void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
    {
        if (Keyboard.Modifiers == ModifierKeys.Shift)
        {
            var scrollInfo = _scrollInfoProperty.GetValue(AssociatedObject) as IScrollInfo;
            if (e.Delta > 0)
            {
                scrollInfo.MouseWheelLeft();
            }
            else
            {
                scrollInfo.MouseWheelRight();
            }
            e.Handled = true;
        }
    }
}
4개의 좋아요

감사합니다!

확실히 이해가 되었습니다.

소중한 시간 내어 주어 다시 한번 감사드립니다.

:slight_smile:

2개의 좋아요

@al6uiz 님이 완벽히 답해 주신거 같은데

개인적으론 mvvm에 너무 얽매이지 않는 걸 추천 드립니다.

wpf mvvm 하는 분들 중에 가끔 view의 cs 코드는

InitializeComponent()

하나로 끝나야 된다고 하시는분이 가끔 계신데

화면 스크롤의 경우는 vm 단이 아니라 v 단 액션입니다.(개인 의견입니다.)

데이터 변경, command 액션 같은 경우는 vm 쪽에서 처리 하는게 맞는데

화면 스크롤은 개인적으론 view의 cs에서 처리 해도 나쁘지 않다 생각합니다.

자주쓰는거면 위에 알려 주신거처럼 behavior 로 만들어서
재사용 하면 될거 같습니다.

6개의 좋아요

의견 감사합니다.

여러모로 잘 따져봐야 할 것 같내요

소중한 시간 내주셔서 다시한번 감사합니다.

1개의 좋아요

논점에서 벗어난 이야기 일 수 있지만 로지텍 MX master의 옆에 달린 가로 스크롤 휠로는 동작이 안되네요.

아 그런가요 ? MX 에서 가로 스크롤을 어떻게 구현한지 알수있으면 적용 가능 할 것 같은데, 주변에 MX 쓰시는분이 없네요 ㅠ

채찍피티씨에게 물어본 답변입니다. mx master 가로스크롤 잘되네요~
쓸일은 많이 없을 것 같지만용

using Microsoft.Xaml.Behaviors;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;

namespace HorizontalScrollViewerExample
{
    public class HorizontalScrollBehavior : Behavior<ScrollViewer>
    {
        private const int WM_MOUSEHWHEEL = 0x020E;

        protected override void OnAttached()
        {
            base.OnAttached();
            AssociatedObject.Loaded += OnLoaded;
            AssociatedObject.Unloaded += OnUnloaded;
        }

        private void OnLoaded(object sender, RoutedEventArgs e)
        {
            var window = Window.GetWindow(AssociatedObject);
            if (window != null)
            {
                var source = HwndSource.FromHwnd(new WindowInteropHelper(window).Handle);
                source.AddHook(WndProc);
            }
        }

        private void OnUnloaded(object sender, RoutedEventArgs e)
        {
            var window = Window.GetWindow(AssociatedObject);
            if (window != null)
            {
                var source = HwndSource.FromHwnd(new WindowInteropHelper(window).Handle);
                source.RemoveHook(WndProc);
            }
        }

        private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            if (msg == WM_MOUSEHWHEEL)
            {
                int delta = (short)HIWORD(wParam);
                if (delta > 0)
                {
                    AssociatedObject.LineRight();
                }
                else if (delta < 0)
                {
                    AssociatedObject.LineLeft();
                }
                handled = true;
            }

            return IntPtr.Zero;
        }

        private int HIWORD(IntPtr ptr)
        {
            uint val = unchecked((uint)(long)ptr);
            return (short)(val >> 16);
        }

        protected override void OnDetaching()
        {
            var window = Window.GetWindow(AssociatedObject);
            if (window != null)
            {
                var source = HwndSource.FromHwnd(new WindowInteropHelper(window).Handle);
                source.RemoveHook(WndProc);
            }
            AssociatedObject.Loaded -= OnLoaded;
            AssociatedObject.Unloaded -= OnUnloaded;
            base.OnDetaching();
        }
    }
}

<Window x:Class="HorizontalScrollViewerExample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:HorizontalScrollViewerExample"
        xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
        Title="Horizontal Scroll Viewer" Height="200" Width="400">
    <Grid>
        <ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
            <i:Interaction.Behaviors>
                <local:HorizontalScrollBehavior />
            </i:Interaction.Behaviors>
            <StackPanel Orientation="Horizontal">
                <!-- 가로로 나열할 항목들 -->
                <Button Content="Item 1" Width="100" Height="100" Margin="5"/>
                <Button Content="Item 2" Width="100" Height="100" Margin="5"/>
                <Button Content="Item 3" Width="100" Height="100" Margin="5"/>
                <Button Content="Item 4" Width="100" Height="100" Margin="5"/>
                <Button Content="Item 5" Width="100" Height="100" Margin="5"/>
                <Button Content="Item 6" Width="100" Height="100" Margin="5"/>
            </StackPanel>
        </ScrollViewer>
    </Grid>
</Window>

예제까지 !!
감사합니다 !

1개의 좋아요