Страницы

Поиск по вопросам

воскресенье, 15 декабря 2019 г.

Как на WPF правильно сверстать такой календарь? Сложный компонент с кастомным дизайном

#c_sharp #wpf #xaml #mvvm


Допустим у  меня есть классы:

public class YearOfLife
{
    public int YearAbsolute;
    public int YearRelative;

    public List Weeks = new List();
}

public class Week
{
    public string Tooltip;

    public WeekType WeekType;

    public DateTime Start;
    public DateTime End;
}

public enum WeekType
{
    Empty,
    Passed,
    Future,
    PossibleFuture
}


И допустим у меня есть код который генерирует данные:

List Life = new List()

public void ReGenerateLife()
{
    Life.Clear();

    var yearStart = new DateTime(DayBirth.Year, 1, 1, 0, 0, 0);
    yearStart = yearStart.AddDays(8 - (int)yearStart.DayOfWeek);

    for (var i = yearStart; i <= DayDeath2; i = i.AddYears(1))
    {
        var tmpYear = new YearOfLife();
        tmpYear.YearAbsolute = i.Year;
        tmpYear.YearRelative = i.Year - DayBirth.Year;

        Life.Add(tmpYear);
    }

    var currDate = yearStart;

    while (currDate <= DayDeath2)
    {
        var lstItem = Life.Where(a => a.YearAbsolute == currDate.Year).ToList()[0];

        var week = new Week();
        week.Start = currDate;
        week.End = currDate.AddDays(6);

        if (currDate < DayBirth)
        {
            week.WeekType = WeekType.Empty;
        }
        else if (currDate <= DateTime.Today)
        {
            week.WeekType = WeekType.Passed;
        }
        else if (currDate <= DayDeath)
        {
            week.WeekType = WeekType.Future;
        }
        else if (currDate <= DayDeath2)
        {
            week.WeekType = WeekType.PossibleFuture;
        }

        lstItem.Weeks.Add(week);
        currDate = currDate.AddDays(7);
    }
}


Заполнение работает на отлично (хотя и требует некоторых доработок):


Есть еще заготовка на XAML где решена часть задачи:


    
    


   
    
        
            
                
                    
                        
                        
                        
                    
                    
                        
                    
                    
                    
                    
                        
                            
                        
                        
                            
                        
                        
                        
                    
                
            
        
    



Результат должен получится приблизительно следующий:


Более детальный пример результата

При этом я хотел бы получить возможность скейла вместе с окном, а клетки что бы оставались
при этом квадратными (реализовал частично)

Вопросы:


Как сделать на основе тех данных которые генерируются в Life вывод кнопок на каждую
"неделю" ? Не писать же кнопку на каждую неделю в году?(строке) что бы потом выводить
ее биндингом вручную?
Как сделать отступы на каждых 5ти клетках? (вертикально и горизонтально)
Как сделать изменение цвета кнопки в зависимости от WeekType?
Как сделать автоизменение шрифта количества лет в зависимости от размера окна в неких
рамках?


....

Короче, как добиться того результата, который на картинке, если учесть что я хочу
это все сделать ПРАВИЛЬНО с точки зрения работы с WPFом, а не просто сделать.

Нужен код + объяснение почему было сделано так/почему такой подход правильный. Без
фанатичного разжевывания. А только на основных идеях.
    


Ответы

Ответ 1



В вашем макете основополагающим размером является размер ячейки календаря, габариты заголовков зависят от него. Поскольку я не смог придумать изящного способа пробросить результат измерения размера ячейки из панели лежащей в шаблоне ItemsControl наружу, то мы поступим наоборот — вычислим размер ячейки снаружи и будем его передавать и в панель самого календаря и в панели его заголовков. Бонусом — можно использовать одну и ту же панель как для календаря, так и для его заголовков. Итак, бросим в корневой Grid окна безобидный ContentControl и с помощью конвертера запишем в его Tag размеры ячейки. У меня есть такая заготовка для конвертеров: abstract class MultiConverterBase : MarkupExtension, IMultiValueConverter { public abstract object Convert(object[] values, Type targetType, object parameter, CultureInfo culture); public virtual object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) => throw new NotImplementedException(); public override object ProvideValue(IServiceProvider serviceProvider) => this; } Она реализует MarkupExtension, поэтому её чуть удобнее использовать в разметке — не нужно создавать дополнительный ресурс. Итак, конвертер: class SplitGridCellLengthConverter : MultiConverterBase { public override object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) { var totalLength = (double)values[0]; var cells = (int)values[1]; var shortSpace = (double)values[2]; var longSpace = (double)values[3]; var longSpacePeriod = (int)values[4]; var totalNum = Math.Max(cells - 1, 0); var longNum = totalNum / longSpacePeriod; var shortNum = totalNum - longNum; var totalSpace = longNum * longSpace + shortNum * shortSpace; return Math.Max(totalLength - totalSpace, 0) / cells; } } Нам потребуются некоторые константы, вроде количества столбцов в календаре и прочего, возможно они у вас будут лежать в VM, я же использую еще пачку расширений разметки: class ValueExtension : MarkupExtension { public T Value { get; set; } public ValueExtension() { } public ValueExtension(T value) => Value = value; public override object ProvideValue(IServiceProvider serviceProvider) => Value; } class IntExtension : ValueExtension { public IntExtension() { } public IntExtension(int value) : base(value) { } } class DoubleExtension : ValueExtension { public DoubleExtension() { } public DoubleExtension(double value) : base(value) { } } Теперь пишем: Теперь, когда у нас есть размер ячейки, мы можем его использовать как высоту и ширину ячеек календаря (квадратные же), а также как высоту заголовков строк и ширину заголовков столбцов Теперь пишем панель, которая будет размещать свои элементы в ячейках одинакового размера с большими и малыми отступами: public class SplitGrid : Panel { #region AP public static int GetColumn(DependencyObject obj) => (int)obj.GetValue(ColumnProperty); public static void SetColumn(DependencyObject obj, int value) => obj.SetValue(ColumnProperty, value); public static readonly DependencyProperty ColumnProperty = DependencyProperty.RegisterAttached("Column", typeof(int), typeof(SplitGrid), new PropertyMetadata(0)); public static int GetRow(DependencyObject obj) => (int)obj.GetValue(RowProperty); public static void SetRow(DependencyObject obj, int value) => obj.SetValue(RowProperty, value); public static readonly DependencyProperty RowProperty = DependencyProperty.RegisterAttached("Row", typeof(int), typeof(SplitGrid), new PropertyMetadata(0)); #endregion AP #region DP public int Columns { get => (int)GetValue(ColumnsProperty); set => SetValue(ColumnsProperty, value); } public static readonly DependencyProperty ColumnsProperty = DependencyProperty.Register(nameof(Columns), typeof(int), typeof(SplitGrid), new FrameworkPropertyMetadata(1, FrameworkPropertyMetadataOptions.AffectsMeasure), ValidateColumns); private static bool ValidateColumns(object value) => (int)value > 0; public int Rows { get => (int)GetValue(RowsProperty); set => SetValue(RowsProperty, value); } public static readonly DependencyProperty RowsProperty = DependencyProperty.Register(nameof(Rows), typeof(int), typeof(SplitGrid), new FrameworkPropertyMetadata(1, FrameworkPropertyMetadataOptions.AffectsMeasure), ValidateRows); private static bool ValidateRows(object value) => (int)value > 0; public double ShortSpace { get => (double)GetValue(ShortSpaceProperty); set => SetValue(ShortSpaceProperty, value); } public static readonly DependencyProperty ShortSpaceProperty = DependencyProperty.Register(nameof(ShortSpace), typeof(double), typeof(SplitGrid), new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsMeasure)); public double LongSpace { get => (double)GetValue(LongSpaceProperty); set => SetValue(LongSpaceProperty, value); } public static readonly DependencyProperty LongSpaceProperty = DependencyProperty.Register(nameof(LongSpace), typeof(double), typeof(SplitGrid), new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsMeasure)); public int LongSpacePeriod { get { return (int)GetValue(LongSpacePeriodProperty); } set { SetValue(LongSpacePeriodProperty, value); } } public static readonly DependencyProperty LongSpacePeriodProperty = DependencyProperty.Register(nameof(LongSpacePeriod), typeof(int), typeof(SplitGrid), new FrameworkPropertyMetadata(5, FrameworkPropertyMetadataOptions.AffectsMeasure), ValidateLongSpacePeriod); private static bool ValidateLongSpacePeriod(object value) => (int)value > 0; public double CellWidth { get => (double)GetValue(CellWidthProperty); set => SetValue(CellWidthProperty, value); } public static readonly DependencyProperty CellWidthProperty = DependencyProperty.Register(nameof(CellWidth), typeof(double), typeof(SplitGrid), new FrameworkPropertyMetadata(double.PositiveInfinity, FrameworkPropertyMetadataOptions.AffectsMeasure)); public double CellHeight { get => (double)GetValue(CellHeightProperty); set => SetValue(CellHeightProperty, value); } public static readonly DependencyProperty CellHeightProperty = DependencyProperty.Register(nameof(CellHeight), typeof(double), typeof(SplitGrid), new FrameworkPropertyMetadata(double.PositiveInfinity, FrameworkPropertyMetadataOptions.AffectsMeasure)); #endregion DP // Этап подсчета занимаемого места protected override Size MeasureOverride(Size constraint) { columns = Columns; rows = Rows; shortSpace = ShortSpace; longSpace = LongSpace; longSpacePeriod = LongSpacePeriod; cellWidth = CellWidth; cellHeight = CellHeight; var cellSize = new Size(cellWidth, cellHeight); // Обязанность панели запросить желаемое место элементов foreach (UIElement child in InternalChildren) child.Measure(cellSize); // Если размеры ячейки не заданы, выбираем размер наибольшего элемента if (double.IsInfinity(cellWidth)) cellWidth = InternalChildren.Cast().Max(child => child.DesiredSize.Width); if (double.IsInfinity(cellHeight)) cellHeight = InternalChildren.Cast().Max(child => child.DesiredSize.Height); // Итоговые желаемые размеры панели double width = CalcTotalSpace(columns - 1) + columns * cellWidth; double height = CalcTotalSpace(rows - 1) + rows * cellHeight; return new Size(width, height); } // Этап размещения элементов protected override Size ArrangeOverride(Size arrangeSize) { foreach (UIElement child in InternalChildren) { int column = GetColumn(child); int row = GetRow(child); double x = column * cellWidth + CalcTotalSpace(column); double y = row * cellHeight + CalcTotalSpace(row); var childBounds = new Rect(x, y, cellWidth, cellHeight); // Размещаем child.Arrange(childBounds); } double width = CalcTotalSpace(columns - 1) + columns * cellWidth; double height = CalcTotalSpace(rows - 1) + rows * cellHeight; return new Size(width, height); } private int columns; private int rows; private double shortSpace; private double longSpace; private int longSpacePeriod; private double cellWidth; private double cellHeight; private double CalcTotalSpace(int totalNum) { if (totalNum < 0) return 0; int longNum = totalNum / longSpacePeriod; int shortNum = totalNum - longNum; return longNum * longSpace + shortNum * shortSpace; } } Тестируем. VM: class MainVm : Vm { public List Weeks { get; } = new List(); public MainVm() { // Считаем что неделя относится к тому году, // к которому относится понедельник var date = new DateTime(2000, 1, 1); while (date.DayOfWeek != DayOfWeek.Monday) date = date.AddDays(1); var endDate = new DateTime(2010, 1, 1); int weekNum = 0; while (date < endDate) { int year = date.Year; var week = new WeekVm(year - 2000, weekNum); Weeks.Add(week); date = date.AddDays(7); if (date.Year > year) weekNum = 0; else weekNum++; } } } class WeekVm : Vm { public int YearNum { get; } public int WeekNum { get; } public WeekVm(int yearNum, int weekNum) { YearNum = yearNum; WeekNum = weekNum; } } В корневой грид добавляем: Запускаем: Теперь заголовки. Я воспользовался идеей отсюда и написал такой класс для представления заголовка на стороне View: class HeaderItem { public int Index { get; } public string Text { get; } public HeaderItem(int index, string text) { Index = index; Text = text; } } И такой конвертер, который будет генерировать нам список HeaderItem: class HeaderListConverter : MultiConverterBase { public override object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) { var start = (int)values[0]; var count = (int)values[1]; var delta = (int)values[2]; var headerList = Enumerable .Range(start, count) .Where(h => h % 5 == 0) .Select(h => new HeaderItem(h - delta, $"{h}")); return headerList; } } Используем внутри корневого Grid: Я привязал размер шрифта заголовка к размеру ячейки, это не самое гибкое решение, но вы можете написать конвертер, который будет использовать некий коэффициент масштаба, а также принимать максимальное и минимальное значение размера шрифта. Тут вы уже можете заметить, что изменение размеров окна начинает притормаживать (если в настройках Windows включена опция "Отображать содержимое окна при перетаскивании), т. к. на каждый пиксель (даже чаще) пройденный мышью происходит пересчет размеров ячейки, потом отрисовка заголовков, при этом место под календарь тоже меняется, меняется размер ячейки и т. д. пока не будут рассчитаны окончательные размеры. Я написал немного кода для перехвата сообщений и выставления простой текстовой заглушки вместо контента окна: public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); } protected override void OnSourceInitialized(EventArgs e) { base.OnSourceInitialized(e); HwndSource source = HwndSource.FromHwnd(new WindowInteropHelper(this).Handle); source.AddHook(WndProc); } private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { switch (msg) { case 0x0231: // WM_ENTERSIZEMOVE content = Content; Content = new TextBlock { Text = "Обновление...", HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; break; case 0x0232: // WM_EXITSIZEMOVE Content = content; break; } return IntPtr.Zero; } private object content; } Вы можете вместо этого выставить в окне DP и поставить на него триггер в разметке и выводить какую-то более привлекательную заглушку. продолжение следует... Репозиторий с проектом на GitHub: WpfSplitGrid

Комментариев нет:

Отправить комментарий