Creating custom virtualized controls in WinRT/UWP
Asked Answered
G

2

6

In WPF it is possible for a FrameworkElement derived class to provide its own children via AddVisualChild. This way it is possible to implement your own virtualized controls which only generate the children which are visible. Also you can generate children without having a backing collection.

I want to port several controls using this technique from WPF to Windows 10 UWP but it is unclear how to properly implement virtualization in WinRT UI. Because in a comment on my original version of the question it was stated that asking about implementation techniques is too general for Stack Overflow I've created a minimalistic example to explain the key features I'm trying to cover, which are

  • dynamically generating child controls from the data model
  • performing custom layout logic for the generated child controls

I've done following considerations:

  • As far as I can see it is not possible for a custom control to manage its own children like in WPF
  • I'm ruling out a Panel subclass because when my custom control is used (by someone else) it is far too easy to make mistakes. The panel children are supposed to be controlled by the containing XAML not by the panel.
  • I'm ruling out ItemsControl subclasses because it is not reasonably possible to provide a backing collection (data virtualization is a requirement)

(Note that ruling them out may be a mistake, so if it is please point it out.)

The following WPF Code creates an infinite scrolling date band but only materializes the currently visible cells. I intentionally kept it as minimalistic as possible so it does not make much sense, but it does present the two key features I mentioned above and which I need to understand how to implement in WinRT.

So my question: is it possible to create such a control in WinRT which dynamically builds its children to display an infinite scrolling band? Keep in mind it needs to be self-contained in order to be placed on arbitrary pages without the page having to contain additional code (otherwise it wouldn't be a reusable control after all).

I'd consider it enough for an answer to outline how it could be done in WinRT, if you already know how to implement virtualization and can just give me some hints.

WPF Source:

using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;

namespace Sandbox
{
    public class DateBand : FrameworkElement
    {
        public static readonly DependencyProperty ScrollOffsetProperty = DependencyProperty.Register(
            nameof(ScrollOffset), typeof(double), typeof(DateBand), new FrameworkPropertyMetadata {
                AffectsMeasure = true,
            });

        public double ScrollOffset
        {
            get { return (double)GetValue(ScrollOffsetProperty); }
            set { SetValue(ScrollOffsetProperty, value); }
        }

        public static readonly DependencyProperty CellTemplateProperty = DependencyProperty.Register(
            nameof(CellTemplate), typeof(DataTemplate), typeof(DateBand), new FrameworkPropertyMetadata {
                AffectsMeasure = true,
            });

        public DataTemplate CellTemplate
        {
            get { return (DataTemplate)GetValue(CellTemplateProperty); }
            set { SetValue(CellTemplateProperty, value); }
        }

        private List<DateCell> _cells = new List<DateCell>();
        private DateTime _startDate = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
        private const double cSlotWidth = 5;
        private const double cSlotHeight = 20;

        protected override int VisualChildrenCount => _cells.Count;
        protected override Visual GetVisualChild(int index) => _cells[index];

        protected override Size MeasureOverride(Size availableSize)
        {
            int usedCells = 0;
            double desiredWidth = 0;
            double desiredHeight = 0;

            if (!double.IsPositiveInfinity(availableSize.Height))
            {
                var index = (int)Math.Floor(ScrollOffset);
                var offset = (index - ScrollOffset) * cSlotHeight;

                while (offset < availableSize.Height)
                {
                    DateCell cell;
                    if (usedCells < _cells.Count)
                    {
                        cell = _cells[usedCells];
                    }
                    else
                    {
                        cell = new DateCell();
                        AddVisualChild(cell);
                        _cells.Add(cell);
                    }
                    usedCells++;

                    var cellValue = _startDate.AddMonths(index);
                    cell._offset = offset;
                    cell._width = DateTime.DaysInMonth(cellValue.Year, cellValue.Month) * cSlotWidth;
                    cell.Content = cellValue;
                    cell.ContentTemplate = CellTemplate;
                    cell.Measure(new Size(cell._width, cSlotHeight));

                    offset += cSlotHeight;
                    index++;

                    desiredHeight = Math.Max(desiredHeight, offset);
                    desiredWidth = Math.Max(desiredWidth, cell._width);
                }
            }

            if (usedCells < _cells.Count)
            {
                for (int i = usedCells; i < _cells.Count; i++)
                    RemoveVisualChild(_cells[i]);

                _cells.RemoveRange(usedCells, _cells.Count - usedCells);
            }

            return new Size(desiredWidth, desiredHeight);
        }

        protected override Size ArrangeOverride(Size finalSize)
        {
            foreach (var cell in _cells)
                cell.Arrange(new Rect(0, cell._offset, cell._width, cell.DesiredSize.Height));

            return finalSize;
        }
    }

    public class DateCell : ContentControl
    {
        internal double _offset;
        internal double _width;
    }

    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private void Window_MouseWheel(object sender, MouseWheelEventArgs e)
        {
            Band.SetCurrentValue(DateBand.ScrollOffsetProperty, Band.ScrollOffset - e.Delta / Mouse.MouseWheelDeltaForOneLine);
        }
    }
}

WPF XAML:

<Window x:Class="Sandbox.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:Sandbox"
        MouseWheel="Window_MouseWheel">
    <DockPanel>
        <ScrollBar x:Name="Scroll" Orientation="Vertical" Minimum="-24" Maximum="+24" ViewportSize="6"/>
        <local:DateBand x:Name="Band" ScrollOffset="{Binding ElementName=Scroll, Path=Value, Mode=OneWay}">
            <local:DateBand.CellTemplate>
                <DataTemplate>
                    <Border BorderBrush="Black" BorderThickness="1" Padding="5,2">
                        <TextBlock Text="{Binding StringFormat='yyyy - MMMM'}"/>
                    </Border>
                </DataTemplate>
            </local:DateBand.CellTemplate>
        </local:DateBand>
    </DockPanel>
</Window>
Genagenappe answered 27/11, 2015 at 16:7 Comment(9)
"since controls cannot control their children as far as I can see" -- that's my understanding as well, though I am far from a Winrt expert so it's possible there's something I've overlooked. When I had the same goal as you have here (porting WPF with custom control to Winrt), the only solution I found was to implement the custom control as a subclass of an existing container (Grid, in my case) and then manage the child elements as full UI elements (as opposed to Visual, as in the WPF implementation). As you can imagine, this is far from ideal. :(Amabelle
I will warn you that your question as stated is borderline "too broad" for Stack Overflow. It's very generalized. It would help if you would provide a good minimal reproducible example that shows exactly what you're doing, along with a specific problem statement that gives a narrowly-focused goal relative to what you've got working so far. I find the question useful, but you'll probably need to improve it to get good answers (assuming you get answers at all).Amabelle
@PeterDuniho I don't know why it is "too broad", I'm asking how to translate a common virtualization technique in WPF into whatever virtualization technique is available in WinRT. I've made up a minimum code example representing the technique in WPF. Other than that I'm not sure how to improve the question, if you could point me more directly at what is unclear I may explain it in more detail.Genagenappe
"I don't know why it is "too broad"" -- because there are too many possible answers. A comprehensive reply would be too long, and contain too many different scenarios, to be appropriate for the Stack Overflow model. As for the code example you provided, that's helpful, but you really should provide a good minimal reproducible example showing what you've tried in the Winrt version, with a precise explanation of a specific problem you are trying to solve in that version.Amabelle
@PeterDuniho I reformulated the question and based it on the example code. I don't have anything I can show for WinRT because the whole point of the question is to figure out how to do virtualization in WinRT in the first place.Genagenappe
@Genagenappe Did you have solved this problem? I want to make my own virtualization too. But I don't know how to get the goal.Merrygoround
@Land Sort of, I'll write an example later today, but basically you can't get around subclassing Panel and have to live with the disadvantages that come with exposing implementation details to users of your control.Genagenappe
@Genagenappe It is wonderful if you can give me an easy example, I am really confused by the custom virtualization.Merrygoround
@Land I don't think you can get it any simpler. Virtualization means you generate the children at runtime, usually in the MeasureOverride. How you generate the children entirely depends on what you are building. If you have specific problems you probably should post them in a new question, if you need a general idea about virtualization you probably should search for how it is done in WPF, there are much more resources around than for UWP, and they are quite similar.Genagenappe
G
2

As requested in a comment I'm posting the solution I ended up with. I only figured out solutions which use some kind of Panel subclass, so I came up with the compromise of splitting the control into two parts, to avoid users of the control accidently messing with the child collection.

So I actually have two main classes, one Control subclass exposing the public API (like dependency properties) and supporting theming, and a Panel subclass implementing the actual virtualization. Both are linked through the XAML template and the Panel subclass will refuse to perform any work if someone should use it outside the expected control.

Having done that, the virtualization is pretty straightforward and doesn't differ very much from how you would do it in WPF - just modify the Children of the Panel, for example in the MeasureOverride.

For illustration I've ported the code from the question to UWP as follows:

UWP Source:

[TemplatePart(Name = PanelPartName, Type = typeof(DateBandPanel))]
public class DateBand : Control
{
    private const string PanelPartName = "CellPanel";

    public static readonly DependencyProperty ScrollOffsetProperty = DependencyProperty.Register(
        nameof(ScrollOffset), typeof(double), typeof(DateBand), new PropertyMetadata(
            (double)0, new PropertyChangedCallback((d, e) => ((DateBand)d).HandleScrollOffsetChanged(e))));

    private void HandleScrollOffsetChanged(DependencyPropertyChangedEventArgs e)
    {
        _panel?.InvalidateMeasure();
    }

    public double ScrollOffset
    {
        get { return (double)GetValue(ScrollOffsetProperty); }
        set { SetValue(ScrollOffsetProperty, value); }
    }

    public static readonly DependencyProperty CellTemplateProperty = DependencyProperty.Register(
        nameof(CellTemplate), typeof(DataTemplate), typeof(DateBand), new PropertyMetadata(
            null, new PropertyChangedCallback((d, e) => ((DateBand)d).HandleCellTemplateChanged(e))));

    private void HandleCellTemplateChanged(DependencyPropertyChangedEventArgs e)
    {
        _panel?.InvalidateMeasure();
    }

    public DataTemplate CellTemplate
    {
        get { return (DataTemplate)GetValue(CellTemplateProperty); }
        set { SetValue(CellTemplateProperty, value); }
    }

    private DateBandPanel _panel;

    public DateBand()
    {
        this.DefaultStyleKey = typeof(DateBand);
    }

    protected override void OnApplyTemplate()
    {
        if (_panel != null)
            _panel._band = null;

        base.OnApplyTemplate();

        _panel = GetTemplateChild(PanelPartName) as DateBandPanel;

        if (_panel != null)
            _panel._band = this;
    }
}

public class DateBandPanel : Panel
{
    internal DateBand _band;
    private List<DateCell> _cells = new List<DateCell>();
    private DateTime _startDate = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
    private const double cSlotWidth = 5;
    private const double cSlotHeight = 26;

    protected override Size MeasureOverride(Size availableSize)
    {
        int usedCells = 0;
        double desiredWidth = 0;
        double desiredHeight = 0;

        if (!double.IsPositiveInfinity(availableSize.Height) && _band != null)
        {
            var index = (int)Math.Floor(_band.ScrollOffset);
            var offset = (index - _band.ScrollOffset) * cSlotHeight;

            while (offset < availableSize.Height)
            {
                DateCell cell;
                if (usedCells < _cells.Count)
                {
                    cell = _cells[usedCells];
                }
                else
                {
                    cell = new DateCell();
                    Children.Add(cell);
                    _cells.Add(cell);
                }
                usedCells++;

                var cellValue = _startDate.AddMonths(index);
                cell._offset = offset;
                cell._width = DateTime.DaysInMonth(cellValue.Year, cellValue.Month) * cSlotWidth;
                cell.Content = new CellData(cellValue);
                cell.ContentTemplate = _band.CellTemplate;
                cell.Measure(new Size(cell._width, cSlotHeight));

                offset += cSlotHeight;
                index++;

                desiredHeight = Math.Max(desiredHeight, offset);
                desiredWidth = Math.Max(desiredWidth, cell._width);
            }
        }

        if (usedCells < _cells.Count)
        {
            for (int i = usedCells; i < _cells.Count; i++)
                Children.Remove(_cells[i]);

            _cells.RemoveRange(usedCells, _cells.Count - usedCells);
        }

        return new Size(desiredWidth, desiredHeight);
    }

    protected override Size ArrangeOverride(Size finalSize)
    {
        foreach (var cell in _cells)
            cell.Arrange(new Rect(0, cell._offset, cell._width, cell.DesiredSize.Height));

        return finalSize;
    }
}

public class CellData
{
    public DateTime Date { get; }
    public CellData(DateTime date) { this.Date = date; }
}

public class DateCell : ContentControl
{
    internal double _offset;
    internal double _width;
}

public class FormattingConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, string language)
    {
        if (value == null)
            return null;

        if (parameter == null)
            return value.ToString();

        return ((IFormattable)value).ToString((string)parameter, CultureInfo.CurrentCulture);
    }

    object IValueConverter.ConvertBack(object value, Type targetType, object parameter, string language)
    {
        throw new NotSupportedException();
    }
}

public sealed partial class MainPage : Page
{
    public MainPage()
    {
        this.InitializeComponent();
    }

    private void Page_PointerWheelChanged(object sender, PointerRoutedEventArgs e)
    {
        Scroll.Value -= e.GetCurrentPoint(this).Properties.MouseWheelDelta / 120.0;
    }
}

UWP XAML Page:

<Page x:Class="Sandbox.MainPage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:local="using:Sandbox"
      xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
      xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
      mc:Ignorable="d"
      PointerWheelChanged="Page_PointerWheelChanged">
    <Page.Resources>
        <local:FormattingConverter x:Key="FormattingConverter"/>
    </Page.Resources>
    <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="auto"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        <ScrollBar x:Name="Scroll" Grid.Column="0" Orientation="Vertical" IndicatorMode="MouseIndicator" Minimum="-24" Maximum="+24" ViewportSize="6"/>
        <local:DateBand x:Name="Band" Grid.Column="1" ScrollOffset="{Binding ElementName=Scroll, Path=Value, Mode=OneWay}">
            <local:DateBand.CellTemplate>
                <DataTemplate x:DataType="local:CellData">
                    <Border BorderBrush="Black" BorderThickness="1" Padding="5,2">
                        <TextBlock Text="{x:Bind Path=Date, Converter={StaticResource FormattingConverter}, ConverterParameter='yyyy - MMMM'}"/>
                    </Border>
                </DataTemplate>
            </local:DateBand.CellTemplate>
        </local:DateBand>
    </Grid>
</Page>

UWP XAML Theme:

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:local="using:Sandbox">
    <Style TargetType="local:DateBand" >
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="local:DateBand">
                    <Border Background="{TemplateBinding Background}"
                            BorderBrush="{TemplateBinding BorderBrush}"
                            BorderThickness="{TemplateBinding BorderThickness}">
                        <local:DateBandPanel Name="CellPanel"/>
                    </Border>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>
Genagenappe answered 19/9, 2017 at 17:7 Comment(0)
B
0

I would go for TemplatedControl where you can Define the Structure of the COntrols you want inside of your XAML this will be much easier,

I did exactly this when I was building a Visualization Charts Library on Codeplex

Bethsaida answered 8/12, 2015 at 11:36 Comment(2)
Note sure how this is related to the question, since I'm asking how to dynamically generate children representing the visible area (aka virtualization). Since XAML is static I can't do much more then creating templates there to style the controls. That is all fine and I'd certainly do that but that doesn't answer the question how to create and layout the children.Genagenappe
Well you can Override DefaultStyleKey Method, and Get the Parent Control and Start adding your Own Controls inside of it and it will update the Xaml Content at runtimeBethsaida

© 2022 - 2024 — McMap. All rights reserved.