using System; using System.Collections.Generic; using System.ComponentModel; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Data; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; using UbisamBase.Core.Capture; using UbisamBase.Core.Locking; using UbisamBase.Core.Logging; using UbisamBase.Core.Messaging; using UbisamBase.Core.Modules; using UbisamBase.Core.Settings; namespace UbisamBase.Core.Shell; public partial class ShellWindow : Window { /// 전역 탈출 단축키(Ctrl+Shift+F, FullscreenHotkeyInstaller)가 어느 창에서 눌리든 /// 이 인스턴스에 닿을 수 있도록 하는 참조. 앱에 Shell은 하나뿐이다. internal static ShellWindow? Instance { get; private set; } private readonly ShellViewModel viewModel; private MainTabViewModel? lastRealTab; private bool suppressSelectionSync; private bool isKiosk; private bool closeApproved; private bool closeAsking; /// 창모드일 때의 마지막 자리(Win32 좌표). 닫을 때 이 값을 기억한다. private Rect lastWindowedRect; /// 트레이 아이콘. 최소화해서 트레이로 내려갈 때 처음 만든다. private TrayIcon? tray; private ContextMenu? trayMenu; /// 트레이로 내려가기 바로 전의 창 모양. 되살릴 때 그대로 돌려놓는다. private WindowState stateBeforeTray = WindowState.Normal; public ShellWindow(ShellViewModel viewModel) { InitializeComponent(); Instance = this; this.viewModel = viewModel; DataContext = viewModel; viewModel.UiSettings.PropertyChanged += OnUiSettingsChanged; // FullBleed는 첨부 속성이라 바인딩이 스스로 변화를 못 본다 — 바뀌면 여백을 다시 계산한다. PlatformChrome.FullBleedChanged += OnFullBleedChanged; Closed += (_, _) => PlatformChrome.FullBleedChanged -= OnFullBleedChanged; ApplyNavPosition(viewModel.UiSettings.NavPosition); ApplyToastPosition(viewModel.UiSettings.ToastPosition); Loaded += (_, _) => ApplyNavCollapsed(viewModel.UiSettings.NavCollapsed); PreviewKeyDown += ShellWindow_PreviewKeyDown; // 하단 탭 바 = 등록된 실제 탭(MainTabs, 왼쪽에 모임) + "화면 잠금"/"바탕화면"/"종료" 같은 // 동작형 항목(오른쪽 끝에 붙음, ShellStyles.xaml의 DockPanel.Dock=Right 트리거로 처리). // DockPanel은 자식을 등록 순서대로 오른쪽부터 채우므로, 화면에 보일 순서의 정반대 순서로 // 넣어야 한다 — 맨 먼저 넣는 항목이 실제 오른쪽 가장자리를 차지하기 때문이다. // 스크린샷/로그/창모드로는 하단이 아니라 상단 브랜딩 바 버튼(ScreenshotButton 등, XAML)으로 있다. var composite = new CompositeCollection { new CollectionContainer { Collection = viewModel.MainTabs }, // Shutdown()이 아니라 Close()다 — 그래야 ICloseGuard(저장 안 한 변경 확인)를 거친다. new NavActionItem("종료", UbisamBase.Core.Modules.Icon.Power, Close), new NavActionItem("바탕화면", UbisamBase.Core.Modules.Icon.Desktop, () => WindowState = WindowState.Minimized), new NavActionItem("화면 잠금", UbisamBase.Core.Modules.Icon.Lock, () => LockConfirmDialog.ShowAndLock()), // 탭 줄 접기 - 콘텐츠를 세로(또는 가로)로 더 보이게. 접으면 얇은 띠가 남고, 띠나 F11 로 다시 편다. new NavActionItem("탭 접기", UbisamBase.Core.Modules.Icon.CollapseNav, () => SetNavCollapsed(true)) }; NavList.ItemsSource = composite; lastRealTab = viewModel.SelectedMainTab; NavList.SelectedItem = viewModel.SelectedMainTab; NavList.SelectionChanged += NavList_SelectionChanged; NavList.SelectionChanged += (_, _) => MoveTabIndicator(animate: true); NavList.Loaded += (_, _) => MoveTabIndicator(animate: false); NavList.SizeChanged += (_, _) => MoveTabIndicator(animate: false); } /// 닫기 전에 열려 있는 모듈 화면에 "지금 닫아도 되는지" 물어본다(ICloseGuard). /// 묻는 일이 비동기라 일단 닫기를 취소해 두고, 전부 동의하면 그때 다시 Close()를 부른다. /// 모듈이 셸 창의 Closing에 직접 붙어 각자 Cancel/Close를 거는 충돌을 막기 위한 단일 통로다. protected override void OnClosing(CancelEventArgs e) { base.OnClosing(e); if (e.Cancel || closeApproved) { return; } if (closeAsking) { // 이미 묻고 있는 중이면 그 답을 기다린다 — 중복으로 묻지 않는다. e.Cancel = true; return; } var guards = CollectCloseGuards(); if (guards.Count == 0) { return; } e.Cancel = true; _ = AskCloseGuardsAsync(guards); } /// 셸 창이 닫히면 앱도 끝난다 — 로그 창 같은 보조 창이 떠 있어도 남지 않도록 명시적으로 종료한다. protected override void OnClosed(EventArgs e) { SavePlacement(); tray?.Dispose(); base.OnClosed(e); Application.Current?.Shutdown(); } private async Task AskCloseGuardsAsync(List guards) { closeAsking = true; try { foreach (var guard in guards) { if (!await guard.CanCloseAsync()) { return; } } closeApproved = true; Close(); } catch (Exception ex) { // 확인 과정이 깨졌다고 창을 못 닫게 두면 사용자가 앱을 끌 방법이 없어진다. new Logger("Shell").E("닫기 확인 중 오류 — 그대로 닫는다", ex); closeApproved = true; Close(); } finally { closeAsking = false; } } /// 이미 열린 적 있는 탭 화면(과 그 DataContext) 중 ICloseGuard를 구현한 것만 모은다. /// 아직 한 번도 안 연 탭은 만들지 않는다 — 물어보려고 화면을 새로 띄우는 꼴이 되기 때문. private List CollectCloseGuards() { var guards = new List(); foreach (var tab in viewModel.MainTabs) { AddCloseGuard(guards, tab.CreatedContent); foreach (var sub in tab.SubTabs) { AddCloseGuard(guards, sub.CreatedContent); } } return guards; } private static void AddCloseGuard(List guards, object? content) { if (content == null) { return; } if (content is ICloseGuard guard && !guards.Contains(guard)) { guards.Add(guard); } if (content is FrameworkElement element && element.DataContext is ICloseGuard viewModelGuard && !guards.Contains(viewModelGuard)) { guards.Add(viewModelGuard); } } /// 창 핸들이 생겨야 모니터 판별(MonitorHelper)이 가능하므로, 시작 시 kiosk 배치는 /// 여기서 한다(생성자 시점엔 아직 핸들이 없을 수 있음). protected override void OnSourceInitialized(EventArgs e) { base.OnSourceInitialized(e); // 지난번에 창모드로 껐으면 그 자리 · 크기로, 아니면 지금까지처럼 전체화면으로 켠다. var saved = ShellPlacement.Load(ShellPlacement.PathIn(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot)); if (saved is { Fullscreen: false, HasWindowRect: true }) { ApplyWindowed(saved.WindowRect); } else { ApplyKiosk(); } // 창모드에서 최대화(Win+↑ 등)하면 배율이 다른 모니터에서 화면 밖으로 넘칠 수 있다 - 그때 바로잡는다. StateChanged += (_, _) => { FitMaximizedToMonitor(); OnStateChanged(); }; DpiChanged += (_, _) => FitMaximizedToMonitor(); LocationChanged += (_, _) => RememberWindowedRect(); SizeChanged += (_, _) => RememberWindowedRect(); } /// 창모드로 떠 있는 동안의 자리를 들고 있는다. 닫을 때 이 값을 기억한다. private void RememberWindowedRect() { // 트레이로 내려간 동안(감춰짐)에는 창이 화면 밖(-32000)에 있다 - 그 자리를 기억하면 되살릴 때 못 찾는다. if (isKiosk || WindowState != WindowState.Normal || !IsVisible) { return; } var rect = MonitorHelper.GetWindowBounds(this); if (rect.IsEmpty || rect.Width <= 0 || rect.Height <= 0 || IsParked(rect)) { return; } lastWindowedRect = rect; } /// /// 최소화한 창이 잠깐 놓이는 자리(-32000, -32000)인지. /// /// 최소화하는 동안 창이 먼저 저 자리로 옮겨지고 그 다음에 모양이 "최소화"로 바뀐다. /// 그 틈에 자리를 기억해 버리면 되살릴 때 화면 밖이라 어느 모니터에도 걸치지 않아, /// 창이 주 모니터 가운데 최소 크기로 쪼그라들었다. /// private static bool IsParked(Rect rect) => rect.Left <= -30000 || rect.Top <= -30000; /// 마지막 모드와 창모드 자리를 파일에 남긴다. 못 써도 그냥 넘어간다. private void SavePlacement() { var placement = new ShellPlacement { Fullscreen = isKiosk }; if (lastWindowedRect.Width > 0 && lastWindowedRect.Height > 0) { placement.Left = lastWindowedRect.Left; placement.Top = lastWindowedRect.Top; placement.Width = lastWindowedRect.Width; placement.Height = lastWindowedRect.Height; } placement.Save(ShellPlacement.PathIn(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot)); } /// /// 최대화한 창이 그 모니터보다 크면 모니터 크기로 다시 놓는다. /// /// 배율이 크게 다른 모니터(주 150% 옆 300%)에서 최대화하면 창이 모니터보다 커져 오른쪽 · 아래가 잘렸다. /// 이미 맞으면 아무것도 하지 않아 다시 놓기가 되풀이되지 않는다. /// private void FitMaximizedToMonitor() { if (WindowState != WindowState.Maximized) { return; } var monitor = MonitorHelper.GetBounds(this); var window = MonitorHelper.GetWindowBounds(this); if (window.IsEmpty || !ShellPlacementMath.NeedsMaximizeFix(window, monitor)) { return; } WindowState = WindowState.Normal; Left = monitor.Left; Top = monitor.Top; Width = monitor.Width; Height = monitor.Height; FitToMonitor(monitor); } /// /// 최소화하면 작업 표시줄 대신 트레이(알림 영역)로 내려간다. /// /// 창을 로 감추므로 작업 표시줄 단추도 함께 사라진다 - 되살리는 길은 트레이 아이콘뿐이라 /// 아이콘을 먼저 올리고 나서 감춘다. [바탕화면] 단추로 내려도 같다. /// private void OnStateChanged() { if (WindowState != WindowState.Minimized) { // 최소화가 아닌 마지막 모양을 들고 있는다 - 트레이에서 되살릴 때 이 모양으로 돌아간다. stateBeforeTray = WindowState; return; } if (!IsVisible) { return; } EnsureTray().Show(); Hide(); } /// 트레이에서 창을 되살린다. 내려가기 전 모양(전체화면 · 최대화 · 창모드)으로 돌아간다. private void RestoreFromTray() { Show(); if (isKiosk) { ApplyKiosk(); } else if (stateBeforeTray == WindowState.Maximized) { WindowState = WindowState.Maximized; FitMaximizedToMonitor(); } else { ApplyWindowed(lastWindowedRect, fit: false); } Activate(); tray?.Hide(); } private TrayIcon EnsureTray() { if (tray != null) { return tray; } tray = new TrayIcon(string.IsNullOrWhiteSpace(Title) ? "Ubisam" : Title); tray.Opened += (_, _) => RestoreFromTray(); tray.MenuRequested += (_, _) => ShowTrayMenu(); return tray; } /// 트레이 아이콘 오른쪽 단추 메뉴 - [열기] · [종료]. private void ShowTrayMenu() { if (trayMenu == null) { var open = new MenuItem { Header = "열기" }; open.Click += (_, _) => RestoreFromTray(); var quit = new MenuItem { Header = "종료" }; quit.Click += (_, _) => { // 닫기 확인(ICloseGuard)을 거치려면 창이 떠 있어야 한다 - 되살린 뒤 평소처럼 닫는다. RestoreFromTray(); Close(); }; trayMenu = new ContextMenu(); trayMenu.Items.Add(open); trayMenu.Items.Add(quit); } trayMenu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint; trayMenu.IsOpen = true; } /// 작업표시줄까지 덮는 전체화면. Topmost는 걸지 않는다 — 현장의 다른 경보/알림 창이 /// 이 화면에 가려지면 안 된다는 요구 때문(사용자 결정). 대신 탈출은 이 버튼 또는 /// Ctrl+Shift+F(FullscreenHotkeyInstaller)로 항상 가능하다. private void ApplyKiosk() { isKiosk = true; var bounds = MonitorHelper.GetBounds(this); ResizeMode = ResizeMode.NoResize; WindowState = WindowState.Normal; Left = bounds.Left; Top = bounds.Top; Width = bounds.Width; Height = bounds.Height; Topmost = false; FitToMonitor(bounds); // 버튼 아이콘/툴팁은 "다음에 누르면 어떻게 되는지"를 나타낸다 — 지금 kiosk이므로 다음 동작은 창모드 전환. SetFullscreenButtonState(toWindowed: true); } /// 모니터 경계를 그대로 Width/Height(DIP)에 넣어도 환경에 따라 창이 몇 px 더 크게 /// 만들어져(DPI 배율 반올림 등) 오른쪽·아래가 화면 밖으로 밀리고, 그만큼 하단 네비게이션 바가 /// 잘려 보인다. 그래서 만들어진 창을 실측해 모니터와의 차이만큼 되돌린다. /// 실측값과 목표값은 둘 다 Win32 좌표계라, 그 비율로 DIP 환산까지 함께 처리된다. private void FitToMonitor(Rect bounds) { var actual = MonitorHelper.GetWindowBounds(this); if (actual.IsEmpty || actual.Width <= 0 || actual.Height <= 0 || Width <= 0 || Height <= 0) { return; } var pixelsPerDipX = actual.Width / Width; var pixelsPerDipY = actual.Height / Height; if (pixelsPerDipX <= 0 || pixelsPerDipY <= 0) { return; } Width = bounds.Width / pixelsPerDipX; Height = bounds.Height / pixelsPerDipY; Left -= (actual.Left - bounds.Left) / pixelsPerDipX; Top -= (actual.Top - bounds.Top) / pixelsPerDipY; } /// 이동/리사이즈 가능한 일반 창. WindowStyle은 계속 None(프레임리스, 브랜딩 바와 /// 일관)이지만 ResizeMode=CanResizeWithGrip이면 WPF가 가장자리에 OS 리사이즈 히트영역을 /// 그대로 붙여준다 — 직접 구현할 필요가 없다. 이동은 브랜딩 바 드래그(BrandBar_MouseLeftButtonDown)로. private void ApplyWindowed() => ApplyWindowed(lastWindowedRect); /// /// 이동 · 크기 조절이 되는 창모드. 기억한 자리가 있으면 그 자리로 - 없으면 지금까지처럼 1200×760 가운데. /// /// 기억한 자리는 모니터 안으로 되돌려 놓는다(). /// 모니터를 떼거나 해상도 · 배율이 바뀌면 그 자리가 화면 밖일 수 있어, 그대로 놓으면 창을 잡을 수 없다. /// /// /// 기억한 자리를 모니터 안으로 밀지. 켤 때는 참이다 - 지난번과 모니터 · 배율이 다를 수 있다. /// 트레이에서 되살릴 때는 거짓 - 몇 초 전 그 자리이므로 그대로 놓아야 크기가 달라지지 않는다. /// private void ApplyWindowed(Rect remembered, bool fit = true) { isKiosk = false; ResizeMode = ResizeMode.CanResizeWithGrip; WindowState = WindowState.Normal; if (remembered.Width > 0 && remembered.Height > 0) { var target = fit ? ShellPlacementMath.FitToMonitors(remembered, Displays.WorkAreasDevice()) : remembered; var actual = MonitorHelper.GetWindowBounds(this); var scaleX = actual.IsEmpty || Width <= 0 ? 1 : actual.Width / Width; var scaleY = actual.IsEmpty || Height <= 0 ? 1 : actual.Height / Height; if (scaleX <= 0) scaleX = 1; if (scaleY <= 0) scaleY = 1; Width = target.Width / scaleX; Height = target.Height / scaleY; Left = target.Left / scaleX; Top = target.Top / scaleY; } else { Width = 1200; Height = 760; var work = SystemParameters.WorkArea; Left = work.Left + Math.Max(0, (work.Width - Width) / 2); Top = work.Top + Math.Max(0, (work.Height - Height) / 2); } Topmost = false; RememberWindowedRect(); SetFullscreenButtonState(toWindowed: false); } /// 버튼 툴팁("모드 변경")은 고정이고, 아이콘만 "다음 클릭이 어느 방향인지"를 나타낸다 — /// toWindowed=true(지금 kiosk)면 창모드로 나가는 아이콘을, false(지금 창모드)면 전체화면으로 /// 들어가는 아이콘을 보여준다. private void SetFullscreenButtonState(bool toWindowed) { FullscreenIconPath.Data = (Geometry)FindResource(toWindowed ? "Ubisam.Icon.FullscreenExit" : "Ubisam.Icon.Fullscreen"); } /// 같은 버튼(또는 Ctrl+Shift+F)으로 kiosk ↔ 창모드를 오간다. internal void ToggleFullscreen() { try { if (isKiosk) { ApplyWindowed(); } else { ApplyKiosk(); } } catch (Exception ex) { // 전환 중 예외가 나도 조작 불가 상태(중간 프레임)로 남지 않도록 창모드로 안전 폴백한다. new UbisamBase.Core.Logging.Logger("Shell").E("전체화면 전환 실패", ex); ResizeMode = ResizeMode.CanResizeWithGrip; WindowState = WindowState.Normal; Topmost = false; isKiosk = false; SetFullscreenButtonState(toWindowed: false); } } /// 창모드에서만 브랜딩 바를 끌어 창을 옮길 수 있다. kiosk에서는 화면 전체를 채우고 있어 /// 이동이 의미가 없으므로 무시한다. private void BrandBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (!isKiosk) { DragMove(); } } private void ScreenshotButton_Click(object sender, RoutedEventArgs e) => ScreenshotService.Capture(this); private void LogButton_Click(object sender, RoutedEventArgs e) => LogViewerWindow.ShowOrFocus(this); private void FullscreenButton_Click(object sender, RoutedEventArgs e) => ToggleFullscreen(); private void NavList_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (suppressSelectionSync) { return; } if (NavList.SelectedItem is NavActionItem action) { action.Execute(); // 동작형 항목은 화면 전환이 아니므로 원래 선택돼 있던 실제 탭으로 되돌린다. suppressSelectionSync = true; NavList.SelectedItem = lastRealTab; suppressSelectionSync = false; } else if (NavList.SelectedItem is MainTabViewModel tab) { lastRealTab = tab; viewModel.SelectedMainTab = tab; } } private void OnFullBleedChanged(object? sender, EventArgs e) => BindingOperations.GetMultiBindingExpression(ContentBorder, Border.PaddingProperty)?.UpdateTarget(); private void OnUiSettingsChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(UiSettingsService.NavPosition)) { ApplyNavPosition(((UiSettingsService)sender!).NavPosition); } else if (e.PropertyName == nameof(UiSettingsService.ToastPosition)) { ApplyToastPosition(((UiSettingsService)sender!).ToastPosition); } else if (e.PropertyName == nameof(UiSettingsService.NavCollapsed)) { ApplyNavCollapsed(((UiSettingsService)sender!).NavCollapsed); } } /// 탭 줄을 접거나 편다. 누를 때마다 그 항목만 파일에 남긴다(설정 화면의 저장하지 않은 값은 건드리지 않는다). private void SetNavCollapsed(bool collapsed) { if (viewModel.UiSettings.NavCollapsed == collapsed) { return; } viewModel.UiSettings.NavCollapsed = collapsed; viewModel.UiSettings.SaveNavCollapsed(); } private void NavExpandStrip_Click(object sender, RoutedEventArgs e) => SetNavCollapsed(false); /// F11 로 접고 편다. 다른 키 조합(Ctrl · Alt · Shift)은 모듈 몫으로 둔다. private void ShellWindow_PreviewKeyDown(object sender, KeyEventArgs e) { if (e.Key != Key.F11 || Keyboard.Modifiers != ModifierKeys.None) { return; } SetNavCollapsed(!viewModel.UiSettings.NavCollapsed); e.Handled = true; } private void ApplyNavCollapsed(bool collapsed) { NavList.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible; NavExpandStrip.Visibility = collapsed ? Visibility.Visible : Visibility.Collapsed; if (!collapsed && viewModel.UiSettings.NavPosition == NavPosition.Bottom) { // 접혀 있는 동안에는 폭이 0 이라 밑줄 자리를 잴 수 없다. 편 뒤 배치가 끝나면 다시 맞춘다. Dispatcher.BeginInvoke(new Action(() => MoveTabIndicator(animate: false)), System.Windows.Threading.DispatcherPriority.Loaded); } } /// 토스트 오버레이의 정렬/여백을 UiSettings.ToastPosition에 맞춘다. ToastList는 /// 콘텐츠와 같은 Grid Row에 있어서(ShellWindow.xaml) 상단 브랜딩 바·하단 서브탭 줄과는 /// 애초에 겹치지 않으므로, 여백은 콘텐츠 영역 안쪽 여유만 두면 된다. private void ApplyToastPosition(ToastPosition position) { var isLeft = position is ToastPosition.TopLeft or ToastPosition.BottomLeft; var isTop = position is ToastPosition.TopLeft or ToastPosition.TopRight; ToastList.HorizontalAlignment = isLeft ? HorizontalAlignment.Left : HorizontalAlignment.Right; ToastList.VerticalAlignment = isTop ? VerticalAlignment.Top : VerticalAlignment.Bottom; ToastList.Margin = new Thickness(20); // 상단 배치는 새 토스트가 목록 앞(=쌓임의 시작점=앵커에 가장 가까운 자리)에 와야 위에 뜨고, // 하단 배치는 StackPanel이 항상 위→아래로 쌓기 때문에 뒤에 추가하는 것만으로 이미 앵커(화면 하단)에 // 가장 가깝게 붙는다 — 그래서 위치가 바뀔 때마다 쌓는 방향도 같이 바꿔줘야 한다. ToastService.Current.StackNewestFirst = isTop; } /// 토스트가 새로 뜰 때 배치된 쪽(좌/우) 화면 끝에서 미끄러져 들어오는 애니메이션. private void ToastItem_Loaded(object sender, RoutedEventArgs e) { if (sender is not Border border || border.RenderTransform is not TranslateTransform transform) { return; } var fromLeft = ToastList.HorizontalAlignment == HorizontalAlignment.Left; var distance = border.ActualWidth > 0 ? border.ActualWidth + 60 : 420; var startX = fromLeft ? -distance : distance; transform.BeginAnimation(TranslateTransform.XProperty, new DoubleAnimation(startX, 0, new Duration(TimeSpan.FromMilliseconds(260))) { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } }); // 표시 시간이 끝나면 ToastService가 IsClosing을 켠다 — 그 순간 등장과 같은 방향의 화면 끝으로 // 다시 미끄러져 나가는 퇴장 애니메이션을 재생한다. 컬렉션에서의 실제 제거는 ToastService가 // 이 애니메이션 길이만큼 기다렸다가 한다. if (border.DataContext is ToastMessage toast) { PropertyChangedEventHandler? handler = null; handler = (_, args) => { if (args.PropertyName != nameof(ToastMessage.IsClosing) || !toast.IsClosing) { return; } var exitDistance = border.ActualWidth > 0 ? border.ActualWidth + 60 : 420; var endX = fromLeft ? -exitDistance : exitDistance; border.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(1, 0, new Duration(TimeSpan.FromMilliseconds(220)))); transform.BeginAnimation(TranslateTransform.XProperty, new DoubleAnimation(0, endX, new Duration(TimeSpan.FromMilliseconds(220))) { EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn } }); toast.PropertyChanged -= handler; }; toast.PropertyChanged += handler; border.Unloaded += (_, _) => toast.PropertyChanged -= handler; } } private void ApplyNavPosition(NavPosition position) { DockPanel.SetDock(NavList, position switch { NavPosition.Right => Dock.Right, NavPosition.Bottom => Dock.Bottom, _ => Dock.Left }); var isHorizontal = position == NavPosition.Bottom; NavList.Style = (Style)FindResource(isHorizontal ? "Ubisam.Style.SidebarBar.Horizontal" : "Ubisam.Style.SidebarBar.Vertical"); NavList.Width = isHorizontal ? double.NaN : 140; NavList.Height = double.NaN; // 접었을 때의 띠도 같은 쪽에 붙인다. 아래면 가로 띠(글자 포함), 옆이면 세로 띠(화살표만). DockPanel.SetDock(NavExpandStrip, DockPanel.GetDock(NavList)); NavExpandStrip.Height = isHorizontal ? 16 : double.NaN; NavExpandStrip.Width = isHorizontal ? double.NaN : 16; NavExpandStrip.BorderThickness = position switch { NavPosition.Right => new Thickness(1, 0, 0, 0), NavPosition.Bottom => new Thickness(0, 1, 0, 0), _ => new Thickness(0, 0, 1, 0) }; NavExpandLabel.Visibility = isHorizontal ? Visibility.Visible : Visibility.Collapsed; NavExpandContent.Orientation = isHorizontal ? Orientation.Horizontal : Orientation.Vertical; NavExpandArrowTurn.Angle = position switch { NavPosition.Right => -90, // 왼쪽으로 펼친다 NavPosition.Bottom => 0, // 위로 펼친다 _ => 90 // 오른쪽으로 펼친다 }; if (isHorizontal) { Dispatcher.BeginInvoke(new Action(() => MoveTabIndicator(animate: false))); } } /// 하단 탭의 선택 인디케이터를 선택된 탭 아래로 옮긴다. 좌/우 네비게이션일 때는 인디케이터가 없어 무시된다. private void MoveTabIndicator(bool animate) { if (NavList.Template?.FindName("SelectionIndicator", NavList) is not Border indicator || NavList.Template.FindName("SelectionIndicatorShift", NavList) is not TranslateTransform shift) { return; } if (NavList.SelectedItem == null || NavList.ItemContainerGenerator.ContainerFromItem(NavList.SelectedItem) is not ListBoxItem container || !container.IsArrangeValid) { indicator.Width = 0; return; } var origin = container.TranslatePoint(new Point(0, 0), NavList); var targetX = origin.X; var targetWidth = container.ActualWidth; if (!animate) { shift.BeginAnimation(TranslateTransform.XProperty, null); indicator.BeginAnimation(FrameworkElement.WidthProperty, null); shift.X = targetX; indicator.Width = targetWidth; return; } var duration = new Duration(TimeSpan.FromMilliseconds(260)); var ease = new CubicEase { EasingMode = EasingMode.EaseOut }; shift.BeginAnimation(TranslateTransform.XProperty, new DoubleAnimation(targetX, duration) { EasingFunction = ease, FillBehavior = FillBehavior.HoldEnd }); indicator.BeginAnimation(FrameworkElement.WidthProperty, new DoubleAnimation(targetWidth, duration) { EasingFunction = ease, FillBehavior = FillBehavior.HoldEnd }); } }