diff --git a/WinUIGallery/Helpers/TabViewHelper.cs b/WinUIGallery/Helpers/TabViewHelper.cs index 7ab110cec..7dc6deb24 100644 --- a/WinUIGallery/Helpers/TabViewHelper.cs +++ b/WinUIGallery/Helpers/TabViewHelper.cs @@ -96,5 +96,11 @@ public static void PopulateTabViewContextMenu(MenuFlyout menuFlyout) }; menuFlyout.Items.Add(moveRightItem); } + + // If the context menu ended up with no items at all, then we'll prevent it from being shown. + if (menuFlyout.Items.Count == 0) + { + menuFlyout.Hide(); + } } } diff --git a/WinUIGallery/Helpers/UIHelper.cs b/WinUIGallery/Helpers/UIHelper.cs index c222e0f08..171cd20b5 100644 --- a/WinUIGallery/Helpers/UIHelper.cs +++ b/WinUIGallery/Helpers/UIHelper.cs @@ -63,4 +63,21 @@ static public void AnnounceActionForAccessibility(UIElement ue, string annouceme peer.RaiseNotificationEvent(AutomationNotificationKind.ActionCompleted, AutomationNotificationProcessing.ImportantMostRecent, annoucement, activityID); } + + public static T GetParent<T>(DependencyObject child) where T : DependencyObject + { + DependencyObject current = child; + + while (current != null) + { + if (current is T parent) + { + return parent; + } + + current = VisualTreeHelper.GetParent(current); + } + + return null; + } } diff --git a/WinUIGallery/Helpers/Win32WindowHelper.cs b/WinUIGallery/Helpers/Win32WindowHelper.cs index a01a1f204..ca7283c92 100644 --- a/WinUIGallery/Helpers/Win32WindowHelper.cs +++ b/WinUIGallery/Helpers/Win32WindowHelper.cs @@ -6,8 +6,8 @@ namespace WinUIGallery.Helpers; internal class Win32WindowHelper { - private static WinProc newWndProc = null; - private static nint oldWndProc = nint.Zero; + private WinProc newWndProc = null; + private nint oldWndProc = nint.Zero; private POINT? minWindowSize = null; private POINT? maxWindowSize = null; diff --git a/WinUIGallery/Samples/ControlPages/TabViewPage.xaml b/WinUIGallery/Samples/ControlPages/TabViewPage.xaml index ce0914485..46ff36e2e 100644 --- a/WinUIGallery/Samples/ControlPages/TabViewPage.xaml +++ b/WinUIGallery/Samples/ControlPages/TabViewPage.xaml @@ -104,7 +104,8 @@ <TabViewItem Content="{x:Bind DataContent}" Header="{x:Bind DataHeader}" - IconSource="{x:Bind DataIconSource}" /> + IconSource="{x:Bind DataIconSource}" + ContextFlyout="{x:Bind TabViewContextMenu}" /> </DataTemplate> </TabView.TabItemTemplate> </TabView> diff --git a/WinUIGallery/Samples/SamplePages/TabContentSampleControl.xaml b/WinUIGallery/Samples/SamplePages/TabContentSampleControl.xaml index 3cad36ffd..b6d5ce43b 100644 --- a/WinUIGallery/Samples/SamplePages/TabContentSampleControl.xaml +++ b/WinUIGallery/Samples/SamplePages/TabContentSampleControl.xaml @@ -13,7 +13,7 @@ <TextBlock Text="{Binding}" Style="{ThemeResource TitleTextBlockStyle}" /> <TextBlock Text="Drag the Tab outside of the window to spawn a new window." Style="{ThemeResource SubtitleTextBlockStyle}" /> <TextBlock Text="Notice that the state of the Tab is maintained in the new window. For example, if you toggle the ToggleSwitch ON, it will remain ON in the new window." Style="{ThemeResource BodyTextBlockStyle}" /> - <ToggleSwitch x:Name="ControlToggle" Header="Turn on ProgressRing" Margin="0,8" /> - <ProgressRing IsActive="{x:Bind ControlToggle.IsOn, Mode=OneWay}" HorizontalAlignment="Left" /> + <ToggleSwitch x:Name="ControlToggle" Header="Turn on ProgressRing" Margin="0,8" IsOn="{x:Bind IsInProgress, Mode=TwoWay}" /> + <ProgressRing IsActive="{x:Bind IsInProgress, Mode=OneWay}" HorizontalAlignment="Left" /> </StackPanel> </UserControl> diff --git a/WinUIGallery/Samples/SamplePages/TabContentSampleControl.xaml.cs b/WinUIGallery/Samples/SamplePages/TabContentSampleControl.xaml.cs index 1d9aefc3b..795f1dd5a 100644 --- a/WinUIGallery/Samples/SamplePages/TabContentSampleControl.xaml.cs +++ b/WinUIGallery/Samples/SamplePages/TabContentSampleControl.xaml.cs @@ -1,3 +1,4 @@ +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; namespace WinUIGallery.SamplePages; @@ -8,4 +9,13 @@ public TabContentSampleControl() { this.InitializeComponent(); } + public bool IsInProgress + { + get { return (bool)GetValue(IsInProgressProperty); } + set { SetValue(IsInProgressProperty, value); } + } + + public static readonly DependencyProperty IsInProgressProperty = DependencyProperty.Register("IsInProgress", typeof(bool), typeof(TabContentSampleControl), new PropertyMetadata(false)); + + } diff --git a/WinUIGallery/Samples/SamplePages/TabViewWindowingSamplePage.xaml b/WinUIGallery/Samples/SamplePages/TabViewWindowingSamplePage.xaml index 94d1d0053..cd5bf69d1 100644 --- a/WinUIGallery/Samples/SamplePages/TabViewWindowingSamplePage.xaml +++ b/WinUIGallery/Samples/SamplePages/TabViewWindowingSamplePage.xaml @@ -9,22 +9,36 @@ mc:Ignorable="d"> <Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> - <TabView - x:Name="Tabs" - VerticalAlignment="Stretch" - AddTabButtonClick="Tabs_AddTabButtonClick" - CanTearOutTabs="True" - ExternalTornOutTabsDropped="Tabs_ExternalTornOutTabsDropped" - ExternalTornOutTabsDropping="Tabs_ExternalTornOutTabsDropping" - TabCloseRequested="Tabs_TabCloseRequested" - TabTearOutRequested="Tabs_TabTearOutRequested" - TabTearOutWindowRequested="Tabs_TabTearOutWindowRequested"> + <TabView + x:Name="Tabs" + VerticalAlignment="Stretch" + AddTabButtonClick="Tabs_AddTabButtonClick" + TabCloseRequested="Tabs_TabCloseRequested" + CanTearOutTabs="True" + TabTearOutWindowRequested="Tabs_TabTearOutWindowRequested" + TabTearOutRequested="Tabs_TabTearOutRequested" + ExternalTornOutTabsDropping="Tabs_ExternalTornOutTabsDropping" + ExternalTornOutTabsDropped="Tabs_ExternalTornOutTabsDropped" + TabItemsSource="{x:Bind TabItemDataList}"> <TabView.TabStripHeader> <Grid x:Name="ShellTitleBarInset" Background="Transparent" /> </TabView.TabStripHeader> <TabView.TabStripFooter> <Grid x:Name="CustomDragRegion" Background="Transparent" /> </TabView.TabStripFooter> + <TabView.TabItemTemplate> + <DataTemplate x:DataType="local:TabItemData"> + <TabViewItem Header="{x:Bind Header}"> + <TabViewItem.ContextFlyout> + <MenuFlyout Opening="TabViewContextMenu_Opening" /> + </TabViewItem.ContextFlyout> + <TabViewItem.IconSource> + <SymbolIconSource Symbol="Placeholder" /> + </TabViewItem.IconSource> + <local:TabContentSampleControl DataContext="{x:Bind Content}" IsInProgress="{x:Bind IsInProgress, Mode=TwoWay}" /> + </TabViewItem> + </DataTemplate> + </TabView.TabItemTemplate> </TabView> </Grid> </Page> diff --git a/WinUIGallery/Samples/SamplePages/TabViewWindowingSamplePage.xaml.cs b/WinUIGallery/Samples/SamplePages/TabViewWindowingSamplePage.xaml.cs index 28015b537..fc0aa376a 100644 --- a/WinUIGallery/Samples/SamplePages/TabViewWindowingSamplePage.xaml.cs +++ b/WinUIGallery/Samples/SamplePages/TabViewWindowingSamplePage.xaml.cs @@ -1,16 +1,56 @@ using System.Linq; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Input; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; +using Windows.System; using WinUIGallery.Helpers; +using System; namespace WinUIGallery.SamplePages; +public class TabItemData : DependencyObject +{ + public SymbolIconSource IconSource { get; set; } = new SymbolIconSource() + { + Symbol = Symbol.Placeholder + }; + public string Header + { + get { return (string)GetValue(HeaderProperty); } + set { SetValue(HeaderProperty, value); } + } + + public string Content + { + get { return (string)GetValue(ContentProperty); } + set { SetValue(ContentProperty, value); } + } + + public bool IsInProgress + { + get { return (bool)GetValue(IsInProgressProperty); } + set { SetValue(IsInProgressProperty, value); } + } + + public static readonly DependencyProperty HeaderProperty = DependencyProperty.Register("Header", typeof(string), typeof(TabItemData), new PropertyMetadata("")); + public static readonly DependencyProperty ContentProperty = DependencyProperty.Register("Content", typeof(string), typeof(TabItemData), new PropertyMetadata("")); + public static readonly DependencyProperty IsInProgressProperty = DependencyProperty.Register("Content", typeof(bool), typeof(TabItemData), new PropertyMetadata(false)); +} + public sealed partial class TabViewWindowingSamplePage : Page { private const string DataIdentifier = "MyTabItem"; + private static readonly List<Window> windowList = []; + private static Window tabTearOutWindow = null; + private Win32WindowHelper win32WindowHelper; - private Window tabTearOutWindow = null; + + private readonly ObservableCollection<TabItemData> tabItemDataList = []; + public ObservableCollection<TabItemData> TabItemDataList => tabItemDataList; public TabViewWindowingSamplePage() { @@ -31,6 +71,40 @@ private void TabViewWindowingSamplePage_Loaded(object sender, RoutedEventArgs e) currentWindow.ExtendsContentIntoTitleBar = true; currentWindow.SetTitleBar(CustomDragRegion); CustomDragRegion.MinWidth = 188; + + if (!windowList.Contains(currentWindow)) + { + windowList.Add(currentWindow); + + // We can have a window we're dragging in two different ways: either we created a new window + // for tearing out purposes, or we're dragging an existing window. + // If we created a new window, tabTearOutWindow will be set to that window. + // Otherwise, it won't be set to anything, so we should set it to the window we're currently dragging. + var inputNonClientPointerSource = InputNonClientPointerSource.GetForWindowId(currentWindow.AppWindow.Id); + + double scaleAdjustment = currentWindow.Content.XamlRoot.RasterizationScale; + double titleContentWidth = CustomDragRegion.Width; + double titleContentHeight = CustomDragRegion.Height; + Windows.Graphics.RectInt32 titleBarRect = new Windows.Graphics.RectInt32(0, 0, (int)Math.Round(titleContentWidth * scaleAdjustment), (int)Math.Round(titleContentHeight * scaleAdjustment)); + inputNonClientPointerSource.SetRegionRects(NonClientRegionKind.Passthrough, new Windows.Graphics.RectInt32[] { titleBarRect }); + inputNonClientPointerSource.EnteredMoveSize += (s, args) => + { + if (tabTearOutWindow == null) + { + tabTearOutWindow = currentWindow; + } + }; + + inputNonClientPointerSource.ExitedMoveSize += (s, args) => + { + tabTearOutWindow = null; + }; + + currentWindow.Closed += (s, args) => + { + windowList.Remove(currentWindow); + }; + } } public void LoadDemoData() @@ -38,27 +112,15 @@ public void LoadDemoData() // Main Window -- add some default items for (int i = 0; i < 3; i++) { - Tabs.TabItems.Add(new TabViewItem() { IconSource = new Microsoft.UI.Xaml.Controls.SymbolIconSource() { Symbol = Symbol.Placeholder }, Header = $"Item {i}", Content = new TabContentSampleControl() { DataContext = $"Page {i}" } }); + TabItemDataList.Add(new TabItemData() { IconSource = new Microsoft.UI.Xaml.Controls.SymbolIconSource() { Symbol = Symbol.Placeholder }, Header = $"Item {i}", Content = $"Page {i}" }); } Tabs.SelectedIndex = 0; } - public void AddTabToTabs(TabViewItem tab) - { - Tabs.TabItems.Add(tab); - } - private void Tabs_TabTearOutWindowRequested(TabView sender, TabViewTabTearOutWindowRequestedEventArgs args) { - var newPage = new TabViewWindowingSamplePage(); - - tabTearOutWindow = WindowHelper.CreateWindow(); - tabTearOutWindow.ExtendsContentIntoTitleBar = true; - tabTearOutWindow.Content = newPage; - tabTearOutWindow.AppWindow.SetIcon("Assets/Tiles/GalleryIcon.ico"); - newPage.SetupWindowMinSize(tabTearOutWindow); - + tabTearOutWindow = CreateNewWindow(); args.NewWindowId = tabTearOutWindow.AppWindow.Id; } @@ -68,13 +130,9 @@ private void Tabs_TabTearOutRequested(TabView sender, TabViewTabTearOutRequested { return; } - - var newPage = (TabViewWindowingSamplePage)tabTearOutWindow.Content; - - foreach (TabViewItem tab in args.Tabs.Cast<TabViewItem>()) + if (tabItemDataList.Count > 1) { - GetParentTabView(tab)?.TabItems.Remove(tab); - newPage.AddTabToTabs(tab); + MoveDataItems(TabItemDataList, GetTabItemDataList(tabTearOutWindow), args.Items, 0); } } @@ -85,64 +143,174 @@ private void Tabs_ExternalTornOutTabsDropping(TabView sender, TabViewExternalTor private void Tabs_ExternalTornOutTabsDropped(TabView sender, TabViewExternalTornOutTabsDroppedEventArgs args) { - int position = 0; - - foreach (TabViewItem tab in args.Tabs.Cast<TabViewItem>()) - { - GetParentTabView(tab)?.TabItems.Remove(tab); - sender.TabItems.Insert(args.DropIndex + position, tab); - position++; - } + MoveDataItems(GetTabItemDataList(tabTearOutWindow), TabItemDataList, args.Items, args.DropIndex); } - private TabView GetParentTabView(TabViewItem tab) + private static Window CreateNewWindow() { - DependencyObject current = tab; + var newPage = new TabViewWindowingSamplePage(); + var window = WindowHelper.CreateWindow(); + window.SystemBackdrop = new MicaBackdrop(); + window.ExtendsContentIntoTitleBar = true; + window.Content = newPage; + window.AppWindow.SetIcon("Assets/Tiles/GalleryIcon.ico"); + newPage.SetupWindowMinSize(window); + return window; + } - while (current != null) + private static void MoveDataItems(ObservableCollection<TabItemData> source, ObservableCollection<TabItemData> destination, object[] dataItems, int index) + { + foreach (object tabItemData in dataItems) { - if (current is TabView tabView) - { - return tabView; - } + source.Remove((TabItemData)tabItemData); + destination.Insert(index, (TabItemData)tabItemData); - current = VisualTreeHelper.GetParent(current); + index++; } - - return null; } - private TabViewItem CreateNewTVI(string header, string dataContext) + private static TabView GetTabView(Window window) { - var newTab = new TabViewItem() - { - IconSource = new SymbolIconSource() - { - Symbol = Symbol.Placeholder - }, - Header = header, - Content = new TabContentSampleControl() - { - DataContext = dataContext - } - }; + var tabViewPage = (TabViewWindowingSamplePage)window.Content; + return tabViewPage.Tabs; + } - return newTab; + private static ObservableCollection<TabItemData> GetTabItemDataList(Window window) + { + var tabViewPage = (TabViewWindowingSamplePage)window.Content; + return tabViewPage.TabItemDataList; } private void Tabs_AddTabButtonClick(TabView sender, object args) { - var tab = CreateNewTVI("New Item", "New Item"); - sender.TabItems.Add(tab); + TabItemDataList.Add(new TabItemData() { Header = "New Item", Content = "New Item" }); } private void Tabs_TabCloseRequested(TabView sender, TabViewTabCloseRequestedEventArgs args) { - sender.TabItems.Remove(args.Tab); + TabItemDataList.Remove((TabItemData)args.Item); - if (sender.TabItems.Count == 0) + if (TabItemDataList.Count == 0) { WindowHelper.GetWindowForElement(this).Close(); } } + + private void TabViewContextMenu_Opening(object sender, object e) + { + // The contents of the context menu depends on the state of the application, so we'll build it dynamically. + MenuFlyout contextMenu = (MenuFlyout)sender; + + // We'll first put the generic tab view context menu items in place. + TabViewHelper.PopulateTabViewContextMenu(contextMenu); + + var tabViewItem = (TabViewItem)contextMenu.Target; + ListView tabViewListView = UIHelper.GetParent<ListView>(tabViewItem); + var window = WindowHelper.GetWindowForElement(tabViewItem); + + if (tabViewListView == null) + { + return; + } + + var tabItemDataList = GetTabItemDataList(window); + var tabDataItem = tabViewListView.ItemFromContainer(tabViewItem); + + // Second, we'll include menu items to move this tab to those windows. + MenuFlyoutSubItem moveSubItem = new() { Text = "Move tab to" }; + + // If there are at least two tabs in this window, we'll include the option to move the tab to a new window. + // This option doesn't make sense if there is only one tab, because in that case the source window would have no tabs left, + // and we would effectively be just moving the tab from one window with only one tab to another window with only one tab, + // leaving us in the same state as we started in. + if (tabItemDataList.Count > 1) + { + MenuFlyoutItem newWindowItem = new() { Text = "New window", Icon = new SymbolIcon(Symbol.NewWindow) }; + + newWindowItem.Click += (s, args) => + { + var newWindow = CreateNewWindow(); + MoveDataItems(tabItemDataList, GetTabItemDataList(newWindow), [tabDataItem], 0); + + + // Activating the window and setting its selected item hit a failed assert if the content hasn't been loaded yet, + // so we'll defer these for a tick to allow that to happen first. + DispatcherQueue.TryEnqueue(() => + { + newWindow.Activate(); + GetTabView(newWindow).SelectedItem = tabDataItem; + }); + }; + + moveSubItem.Items.Add(newWindowItem); + } + + // If there are other windows that exist, we'll include the option to move the tab to those windows. + List<MenuFlyoutItem> moveToWindowItems = []; + + foreach (Window otherWindow in windowList) + { + if (window == otherWindow) + { + continue; + } + + var windowTabItemDataList = GetTabItemDataList(otherWindow); + + if (windowTabItemDataList.Count > 0) + { + string moveToWindowItemText = $"Window with \"{windowTabItemDataList[0].Header}\""; + + if (windowTabItemDataList.Count > 1) + { + int remainingTabCount = windowTabItemDataList.Count - 1; + moveToWindowItemText += $" and {remainingTabCount} other tab{(remainingTabCount == 1 ? "" : "s")}"; + } + + MenuFlyoutItem moveToWindowItem = new() { Text = moveToWindowItemText, Icon = new SymbolIcon(Symbol.BackToWindow) }; + moveToWindowItem.Click += (s, args) => + { + MoveDataItems(tabItemDataList, windowTabItemDataList, [tabDataItem], windowTabItemDataList.Count); + + // If removing the tab from its current tab view will leave no tabs remaining, then we'll close the tab view's window. + if (tabItemDataList.Count == 0) + { + window.Close(); + } + + // Activating the window and setting its selected item hit a failed assert if the content hasn't been loaded yet, + // so we'll defer these for a tick to allow that to happen first. + DispatcherQueue.TryEnqueue(() => + { + otherWindow.Activate(); + GetTabView(otherWindow).SelectedItem = tabDataItem; + }); + }; + moveToWindowItems.Add(moveToWindowItem); + } + } + + // Only include a separator if we're going to be including at least one move-to-window item. + if (moveToWindowItems.Count > 0) + { + contextMenu.Items.Add(new MenuFlyoutSeparator()); + } + + foreach (MenuFlyoutItem moveToWindowItem in moveToWindowItems) + { + moveSubItem.Items.Add(moveToWindowItem); + } + + // Only include the move-to sub-item if it has any items. + if (moveSubItem.Items.Count > 0) + { + contextMenu.Items.Add(moveSubItem); + } + + // If the context menu ended up with no items at all, then we'll prevent it from being shown. + if (contextMenu.Items.Count == 0) + { + contextMenu.Hide(); + } + } } diff --git a/WinUIGallery/WinUIGallery.csproj b/WinUIGallery/WinUIGallery.csproj index ec0f7fea9..264b8d47b 100644 --- a/WinUIGallery/WinUIGallery.csproj +++ b/WinUIGallery/WinUIGallery.csproj @@ -644,4 +644,8 @@ </_NoneWithTargetPath> </ItemGroup> </Target> + <!-- Needed in order for running unpackaged to work properly. --> + <Target Name="CopyContentToOutputDirectory" AfterTargets="Build"> + <Copy SourceFiles="@(Content)" DestinationFiles="@(Content->'$(OutDir)%(Identity)')" SkipUnchangedFiles="true" Condition="!$([System.IO.Path]::IsPathRooted('%(Identity)'))" /> + </Target> </Project>