Реализация нового Frame в стиле IOS
Или проще говоря — Frame в стиле Modern UI.
Здравствуйте. Меня зовут Андрей и я очень устал пользоваться стандартным VK на Windows 10. Его горизонтальная навигация меня утомила и как то она не вписывается в общий дизайн. Ещё очень давно хотел реализовать такое дело, а именно: плавная навигация как на iPhone. Для чего? Для того, что я хочу сделать свой VK клиент на WPF. Для начала покажу общую картину:
Можно сделать вывод, что такой подход будет очень удобным. DataContext между страницами будет передаваться через конструктор, но дальше будет интереснее.
Начну с namespace UFC.UI. Так как на каждой странице может находиться несколько кнопок, то мне пришлось создать интерфейс:
public delegate void FloppyPageNavigateEventHandler(IFloppyPage page, FloppyPageEventArgs e);
public delegate void FloppyPageGoBackEventHandler(FloppyPageEventArgs e);
public class FloppyPageEventArgs : EventArgs
{
public FloppyPageEventArgs() { }
}
public interface IFloppyPage
{
event FloppyPageNavigateEventHandler Navigate;
event FloppyPageGoBackEventHandler GoBack;
IFloppyPages IFloppyPages { get; set; }
string Title { get; set; }
}
Каждая страница наследует этот интерфейс и получает очень удобное дополнение.
public partial class Page1 : Page, IFloppyPage
{
public event FloppyPageNavigateEventHandler Navigate;
public event FloppyPageGoBackEventHandler GoBack;
public IFloppyPages IFloppyPages { get; set; }
public Page1() : this(null) { }
public Page1(object dataContext)
{
InitializeComponent();
if (dataContext != null)
this.DataContext = dataContext;
else
this.DataContext = this;
Title = "Первая страница";
}
private void NavigateTo_MainPage(object sender, RoutedEventArgs e)
{
if (Navigate != null)
Navigate(new MainPage(DataContext), new FloppyPageEventArgs());
}
private void NavigateTo_Page2(object sender, RoutedEventArgs e)
{
if (Navigate != null)
Navigate(new Page2(DataContext), new FloppyPageEventArgs());
}
private void NavigateTo_Page3(object sender, RoutedEventArgs e)
{
if (Navigate != null)
Navigate(new Page3(DataContext), new FloppyPageEventArgs());
}
private void Button_GoBack(object sender, RoutedEventArgs e)
{
if (GoBack != null)
GoBack(new FloppyPageEventArgs());
}
}
Теперь плавно можно подойти к интересному. Здесь затронут интерфейс IFloppyPages. Конечно его можно было бы по другому назвать, но я выбрал именно такое название. Его функция ни чем не отличается от DataContext. Такое решение сделано для того, что бы в будущем мы могли использовать DataContext в других целях (mvvm, binding, commands и т.д.)
Собственно, вот его реализация:
public interface IFloppyPages
{
IFloppyPage FirstPage { get; set; }
IFloppyPage CurrentPage { get; set; }
int JournalCount { get; set; }
void Navigate(IFloppyPage page);
bool GoBack();
bool CanGoBack { get; set; }
}
Пожалуй теперь можно взглянуть на xaml разметку этого элемента управления:
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:UFC.UI.Controls">
<Thickness x:Key="Dynamic.LongPage.MarginAnimation">10, 0, -10, 0</Thickness>
<Style TargetType="local:FloppyPages">
<Style.Setters>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:FloppyPages">
<Grid Name="mainGrid">
<Grid Name="grid1">
<Frame Name="frame1" NavigationUIVisibility="Hidden"/>
</Grid>
<Grid Name="grid2">
<Frame Name="frame2" NavigationUIVisibility="Hidden"/>
</Grid>
<Grid.Resources>
<BeginStoryboard x:Key="grid1Animation">
<Storyboard>
<ThicknessAnimation
Duration="0:0:0.8"
Storyboard.TargetName="grid1"
Storyboard.TargetProperty="Margin"
From="{DynamicResource Dynamic.LongPage.MarginAnimation}"
To="0">
<ThicknessAnimation.EasingFunction>
<ElasticEase EasingMode="EaseOut" Oscillations="1"/>
</ThicknessAnimation.EasingFunction>
</ThicknessAnimation>
<ThicknessAnimation
Duration="0:0:0.8"
Storyboard.TargetName="grid2"
Storyboard.TargetProperty="Margin"
From="0"
To="-100, 20, 100, 20">
<ThicknessAnimation.EasingFunction>
<ElasticEase EasingMode="EaseOut" Oscillations="1"/>
</ThicknessAnimation.EasingFunction>
</ThicknessAnimation>
</Storyboard>
</BeginStoryboard>
<BeginStoryboard x:Key="grid2Animation">
<Storyboard>
<ThicknessAnimation
Duration="0:0:0.8"
Storyboard.TargetName="grid2"
Storyboard.TargetProperty="Margin"
From="{DynamicResource Dynamic.LongPage.MarginAnimation}"
To="0">
<ThicknessAnimation.EasingFunction>
<ElasticEase EasingMode="EaseOut" Oscillations="1"/>
</ThicknessAnimation.EasingFunction>
</ThicknessAnimation>
<ThicknessAnimation
Duration="0:0:0.8"
Storyboard.TargetName="grid1"
Storyboard.TargetProperty="Margin"
From="0"
To="-100, 20, 100, 20">
<ThicknessAnimation.EasingFunction>
<ElasticEase EasingMode="EaseOut" Oscillations="1"/>
</ThicknessAnimation.EasingFunction>
</ThicknessAnimation>
</Storyboard>
</BeginStoryboard>
<BeginStoryboard x:Key="grid3Animation">
<Storyboard>
<ThicknessAnimation
Duration="0:0:0.8"
Storyboard.TargetName="grid1"
Storyboard.TargetProperty="Margin"
From="0"
To="{DynamicResource Dynamic.LongPage.MarginAnimation}">
<ThicknessAnimation.EasingFunction>
<ElasticEase EasingMode="EaseOut" Oscillations="1"/>
</ThicknessAnimation.EasingFunction>
</ThicknessAnimation>
<ThicknessAnimation
Duration="0:0:0.8"
Storyboard.TargetName="grid2"
Storyboard.TargetProperty="Margin"
From="-100, 20, 100, 20"
To="0">
<ThicknessAnimation.EasingFunction>
<ElasticEase EasingMode="EaseOut" Oscillations="1"/>
</ThicknessAnimation.EasingFunction>
</ThicknessAnimation>
</Storyboard>
</BeginStoryboard>
<BeginStoryboard x:Key="grid4Animation">
<Storyboard>
<ThicknessAnimation
Duration="0:0:0.8"
Storyboard.TargetName="grid2"
Storyboard.TargetProperty="Margin"
From="0"
To="{DynamicResource Dynamic.LongPage.MarginAnimation}">
<ThicknessAnimation.EasingFunction>
<ElasticEase EasingMode="EaseOut" Oscillations="1"/>
</ThicknessAnimation.EasingFunction>
</ThicknessAnimation>
<ThicknessAnimation
Duration="0:0:0.8"
Storyboard.TargetName="grid1"
Storyboard.TargetProperty="Margin"
From="-100, 20, 100, 20"
To="0">
<ThicknessAnimation.EasingFunction>
<ElasticEase EasingMode="EaseOut" Oscillations="1"/>
</ThicknessAnimation.EasingFunction>
</ThicknessAnimation>
</Storyboard>
</BeginStoryboard>
</Grid.Resources>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style.Setters>
</Style>
</ResourceDictionary>
Очень надеюсь, что вам удастся понять мой алгоритм. Всё самое непонятное постараюсь объяснить после кода внизу страницы.
Теперь приведу весь код этого элемента управления:
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media.Animation;
namespace UFC.UI
{
/// <summary>
/// Страница по умолчанию.
/// </summary>
internal class DefaultPage : IFloppyPage
{
public event FloppyPageNavigateEventHandler Navigate;
public event FloppyPageGoBackEventHandler GoBack;
public IFloppyPages IFloppyPages { get; set; }
public string Title { get; set; }
public DefaultPage()
{
Title = "Страница по умолчанию";
}
}
public delegate void FloppyPageNavigateEventHandler(IFloppyPage page, FloppyPageEventArgs e);
public delegate void FloppyPageGoBackEventHandler(FloppyPageEventArgs e);
public class FloppyPageEventArgs : EventArgs
{
public FloppyPageEventArgs() { }
}
public interface IFloppyPage
{
event FloppyPageNavigateEventHandler Navigate;
event FloppyPageGoBackEventHandler GoBack;
IFloppyPages IFloppyPages { get; set; }
string Title { get; set; }
}
public interface IFloppyPages
{
IFloppyPage FirstPage { get; set; }
IFloppyPage CurrentPage { get; set; }
int JournalCount { get; set; }
void Navigate(IFloppyPage page);
bool GoBack();
bool CanGoBack { get; set; }
}
}
namespace UFC.UI.Controls
{
public class FloppyPages : Control, IFloppyPages, INotifyPropertyChanged
{
#region Private Members
private bool GridNumber = false;
private bool IsDoneAnimation = true;
private List<IFloppyPage> journal = new List<IFloppyPage>();
private Frame frame1 = new Frame();
private Frame frame2 = new Frame();
private Grid mainGrid = new Grid();
private Grid grid1 = new Grid();
private Grid grid2 = new Grid();
private BeginStoryboard animation1 = new BeginStoryboard()
{ Storyboard = new Storyboard() };
private BeginStoryboard animation2 = new BeginStoryboard()
{ Storyboard = new Storyboard() };
private BeginStoryboard animation3 = new BeginStoryboard()
{ Storyboard = new Storyboard() };
private BeginStoryboard animation4 = new BeginStoryboard()
{ Storyboard = new Storyboard() };
#endregion
#region Constructors
static FloppyPages()
{
DefaultStyleKeyProperty.OverrideMetadata(typeof(FloppyPages),
new FrameworkPropertyMetadata(typeof(FloppyPages)));
FloppyPages.NavigatedRoutedEvent =
EventManager.RegisterRoutedEvent("Navigated", RoutingStrategy.Bubble,
typeof(RoutedEventHandler), typeof(FloppyPages));
FloppyPages.WentBackRoutedEvent =
EventManager.RegisterRoutedEvent("WentBack", RoutingStrategy.Bubble,
typeof(RoutedEventHandler), typeof(FloppyPages));
}
public FloppyPages()
{
FirstPage = new DefaultPage();
}
#endregion
#region Public Dependency Properties
public static readonly DependencyProperty FirstPageProperty =
DependencyProperty.RegisterAttached("FirstPage", typeof(IFloppyPage),
typeof(FloppyPages));
#endregion
#region Public Properties
public IFloppyPage FirstPage
{
get { return (IFloppyPage)GetValue(FirstPageProperty); }
set
{
SetValue(FirstPageProperty, value);
OnFirstPage(FirstPage);
OnPropertyChanged("FirstPage");
}
}
#endregion
#region Public RoutedEvents
public static readonly RoutedEvent NavigatedRoutedEvent;
public static readonly RoutedEvent WentBackRoutedEvent;
#endregion
#region Public Events
public event RoutedEventHandler Navigated
{
add { base.AddHandler(FloppyPages.NavigatedRoutedEvent, value); }
remove { base.RemoveHandler(FloppyPages.NavigatedRoutedEvent, value); }
}
public event RoutedEventHandler WentBack
{
add { base.AddHandler(FloppyPages.WentBackRoutedEvent, value); }
remove { base.RemoveHandler(FloppyPages.WentBackRoutedEvent, value); }
}
#endregion
#region Public Members
public IFloppyPage CurrentPage
{
get
{
if (journal.Count > 0)
return journal[journal.Count - 1];
else
return null;
}
set { }
}
public int JournalCount
{
get
{
return journal.Count;
}
set { }
}
public void Navigate(IFloppyPage page)
{
Start_Navigate(page);
}
public bool GoBack()
{
return Start_GoBack();
}
public bool CanGoBack
{
get
{
if (journal.Count > 1)
return true;
else
return false;
}
set { }
}
#endregion
#region Private OnFirstPage
private void OnFirstPage(IFloppyPage page)
{
if (page != null && frame1 != null && frame2 != null)
{
if (GridNumber)
frame1.Navigate(page);
else
frame2.Navigate(page);
page.Navigate += Page_Navigate;
page.GoBack += Page_GoBack;
journal.Clear();
journal.Add(page);
OnPropertyChanged("JournalCount");
OnPropertyChanged("CanGoBack");
OnPropertyChanged("CurrentPage");
}
}
#endregion
#region Public OnApplyTemplate
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
mainGrid = GetTemplateChild("mainGrid") as Grid;
grid1 = GetTemplateChild("grid1") as Grid;
if (grid1 != null)
grid1.Margin = new Thickness(0);
grid2 = GetTemplateChild("grid2") as Grid;
if (grid2 != null)
grid2.Margin = new Thickness(this.ActualWidth, 0, (-1 * this.ActualWidth), 0);
frame1 = GetTemplateChild("frame1") as Frame;
frame2 = GetTemplateChild("frame2") as Frame;
animation1 = mainGrid.Resources["grid1Animation"] as BeginStoryboard;
animation2 = mainGrid.Resources["grid2Animation"] as BeginStoryboard;
animation3 = mainGrid.Resources["grid3Animation"] as BeginStoryboard;
animation4 = mainGrid.Resources["grid4Animation"] as BeginStoryboard;
if (animation1 != null)
if (animation1.Storyboard != null)
animation1.Storyboard.Completed += NewGridMargin_Completed;
if (animation2 != null)
if (animation2.Storyboard != null)
animation2.Storyboard.Completed += NewGridMargin_Completed;
if (animation3 != null)
if (animation3.Storyboard != null)
animation3.Storyboard.Completed += OldGridMargin_Completed;
if (animation4 != null)
if (animation4.Storyboard != null)
animation4.Storyboard.Completed += OldGridMargin_Completed;
if (mainGrid != null)
{
mainGrid.SizeChanged += (sender, e) =>
{
Application.Current.Resources["Dynamic.LongPage.MarginAnimation"] =
new Thickness(this.ActualWidth, 0, -1 * this.ActualWidth, 0);
};
}
OnFirstPage(FirstPage);
}
#endregion
#region Private Events
private void Page_Navigate(IFloppyPage page, FloppyPageEventArgs e)
{
Start_Navigate(page);
}
private void Page_GoBack(FloppyPageEventArgs e)
{
Start_GoBack();
}
private void NewGridMargin_Completed(object sender, EventArgs e)
{
Set_NewMargin();
}
private void OldGridMargin_Completed(object sender, EventArgs e)
{
Set_OldMargin();
}
#endregion
#region Private Navigate
private void Start_Navigate(IFloppyPage page)
{
if (page != null && IsDoneAnimation)
{
IsDoneAnimation = false;
GridNumber = !GridNumber;
page.Navigate += Page_Navigate;
page.GoBack += Page_GoBack;
if (!GridNumber)
{
animation1.Storyboard.Stop();
frame2.Navigate(page);
Panel.SetZIndex(grid1, 0);
Panel.SetZIndex(grid2, 1);
grid2.Visibility = Visibility.Visible;
animation2.Storyboard.Begin();
}
else
{
animation2.Storyboard.Stop();
frame1.Navigate(page);
Panel.SetZIndex(grid2, 0);
Panel.SetZIndex(grid1, 1);
grid1.Visibility = Visibility.Visible;
animation1.Storyboard.Begin();
}
journal.Add(page);
OnPropertyChanged("JournalCount");
OnPropertyChanged("CurrentPage");
OnPropertyChanged("CanGoBack");
base.RaiseEvent(new RoutedEventArgs(FloppyPages.NavigatedRoutedEvent, this));
}
}
private void Set_NewMargin()
{
if (!GridNumber)
{
grid2.Margin = new Thickness(0);
grid1.Margin = new Thickness(this.ActualWidth, 0, (-1 * this.ActualWidth), 0);
grid1.Visibility = Visibility.Hidden;
}
else
{
grid1.Margin = new Thickness(0);
grid2.Margin = new Thickness(this.ActualWidth, 0, (-1 * this.ActualWidth), 0);
grid2.Visibility = Visibility.Hidden;
}
IsDoneAnimation = true;
}
#endregion
#region Private GoBack
private bool Start_GoBack()
{
if (journal.Count > 1 && IsDoneAnimation)
{
IsDoneAnimation = false;
GridNumber = !GridNumber;
grid1.Visibility = Visibility.Visible;
grid2.Visibility = Visibility.Visible;
if (!GridNumber)
{
animation4.Storyboard.Stop();
grid2.Margin = new Thickness(0);
frame2.Navigate(journal[journal.Count - 2]);
animation3.Storyboard.Begin();
}
else
{
animation3.Storyboard.Stop();
grid1.Margin = new Thickness(0);
frame1.Navigate(journal[journal.Count - 2]);
animation4.Storyboard.Begin();
}
journal.Remove(journal[journal.Count - 1]);
OnPropertyChanged("JournalCount");
OnPropertyChanged("CurrentPage");
OnPropertyChanged("CanGoBack");
base.RaiseEvent(new RoutedEventArgs(FloppyPages.WentBackRoutedEvent, this));
return true;
}
else
return false;
}
private void Set_OldMargin()
{
if (!GridNumber)
{
Panel.SetZIndex(grid1, 0);
Panel.SetZIndex(grid2, 1);
grid1.Margin = new Thickness(this.ActualWidth, 0, (-1 * this.ActualWidth), 0);
grid1.Visibility = Visibility.Hidden;
}
else
{
Panel.SetZIndex(grid1, 1);
Panel.SetZIndex(grid2, 0);
grid2.Margin = new Thickness(this.ActualWidth, 0, (-1 * this.ActualWidth), 0);
grid2.Visibility = Visibility.Hidden;
}
IsDoneAnimation = true;
}
#endregion
#region INotifyPropertyChanged Members
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged(string propertyName)
{
PropertyChangedEventHandler handler = PropertyChanged;
if (handler != null)
handler(this, new PropertyChangedEventArgs(propertyName));
}
#endregion
}
}
Начну с того, что ещё ранее вы могли заметить в xaml разметке этого элемента ресурс:
«Dynamic.LongPage.MarginAnimation».
И очень странно, почему там стояли такие размеры: 10,0,-10,0;
На самом деле это не так важно, потому что при сборке мы автоматически подписываемся на событие SizeChanged элемента mainGrid в методе OnApplyTemplate().
if (mainGrid != null)
{
mainGrid.SizeChanged += (sender, e) =>
{
Application.Current.Resources["Dynamic.LongPage.MarginAnimation"] =
new Thickness(this.ActualWidth, 0, -1 * this.ActualWidth, 0);
};
}
Благодаря такой реализации мы получаем элемент управления, где анимация двигается на то расстояние, на которое мы укажем, то есть просто изменив размер окна.
Напомню кстати говоря, что в методе OnApplyTemplate() мы получаем ссылки на все мелкие элементы из разметки методом GetTemplateChild(«mainGrid»);
Алгоритм получился таким: вы как бы видите одну страницу, потом при переходе на следующую страницу с правого края вылезает вторая страница. Первая страница уходит на задний план, затем после окончания анимации первая страница уходит в правый край где была вторая страница.
Таким образом мы получаем две чередующие панели, на которых лежат frame1 и frame2. Благодаря переменной GridNumber мы проверяем, на какой grid мы попали и на каком frame поменять страницу.
Так же здесь реализован журнал, но в нём ничего интересного нет. Обычный список, который удаляет IFloppyPage только после перехода «Назад» (GoBack).
Да и ещё. Как только приложение начинает свою жизнь, ему присваивается первая страница, это может быть либо DefaultPage по умолчанию, либо та страница, которую укажете вы. Затем FloppyPages автоматически привяжет ваш IFloppyPage к событию Navigate и GoBack. Так он будет следить, когда на одной из ваших IFloppyPage вы решитесь перейти на другую страницу.
Теперь покажу окно, где и создаётся FloppyPages, и присваивается первая страница.
using System.Windows;
using UFC.Pages;
namespace UFC
{
public partial class Browser : Window
{
public Browser()
{
InitializeComponent();
floppyPages.FirstPage = new MainPage()
{
IFloppyPages = floppyPages
};
}
private void Button_GoBack(object sender, RoutedEventArgs e)
{
floppyPages.GoBack();
}
}
}
<Window x:Class="UFC.Browser"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ufc="clr-namespace:UFC.UI.Controls;assembly=UFC.UI"
Title="UFC" Height="640" Width="380" >
<Grid Background="LightGray">
<Grid.RowDefinitions>
<RowDefinition Height="40"/>
<RowDefinition/>
</Grid.RowDefinitions>
<ufc:FloppyPages Grid.Row="1" Name="floppyPages" />
<Grid Grid.Row="0" Background="LightGray">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="40"/>
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="1"
VerticalContentAlignment="Center"
HorizontalContentAlignment="Center"
IsReadOnly="True"
Background="Transparent"
FontSize="20"
Text="{Binding ElementName=floppyPages,
Path=CurrentPage.Title,
UpdateSourceTrigger=PropertyChanged}"/>
<Button
Name="MenuButton"
Grid.Column="0"
Visibility="Visible">
<Path
Margin="5"
Stretch="UniformToFill"
Fill="Black"
Data="F1 M 19,23L 27,23L 27,31L 19,31L 19,23 Z M 19,34L 27,34L 27,42L 19,42L 19,34 Z M 31,23L 57,23L 57,31L 31,31L 31,23 Z M 19,45L 27,45L 27,53L 19,53L 19,45 Z M 31,34L 57,34L 57,42L 31,42L 31,34 Z M 31,45L 57,45L 57,53L 31,53L 31,45 Z "/>
</Button>
<Button
Name="BackButton"
Grid.Column="0"
Visibility="Hidden"
Click="Button_GoBack">
<Path
Margin="5,9"
Stretch="UniformToFill"
Fill="Black"
Data="F1 M 18.0147,41.5355C 16.0621,39.5829 16.0621,36.4171 18.0147,34.4645L 26.9646,25.5149C 28.0683,24.4113 29,24 31,24L 52,24C 54.7614,24 57,26.2386 57,29L 57,47C 57,49.7614 54.7614,52 52,52L 31,52C 29,52 28.0683,51.589 26.9646,50.4854L 18.0147,41.5355 Z M 47.5281,42.9497L 42.5784,37.9999L 47.5281,33.0502L 44.9497,30.4717L 40,35.4215L 35.0502,30.4717L 32.4718,33.0502L 37.4215,37.9999L 32.4718,42.9497L 35.0502,45.5281L 40,40.5783L 44.9497,45.5281L 47.5281,42.9497 Z "/>
</Button>
<Button
Grid.Column="2">
<Path
Margin="5,9"
Stretch="UniformToFill"
Fill="Black"
Data="F1 M 57.9853,41.5355L 49.0354,50.4854C 47.9317,51.589 47,52 45,52L 24,52C 21.2386,52 19,49.7614 19,47L 19,29C 19,26.2386 21.2386,24 24,24L 45,24C 47,24 47.9317,24.4113 49.0354,25.5149L 57.9853,34.4645C 59.9379,36.4171 59.9379,39.5829 57.9853,41.5355 Z M 28.4719,42.9497L 31.0503,45.5281L 36,40.5784L 40.9498,45.5281L 43.5282,42.9497L 38.5785,37.9999L 43.5282,33.0502L 40.9498,30.4718L 36,35.4215L 31.0503,30.4718L 28.4719,33.0502L 33.4216,37.9999L 28.4719,42.9497 Z "/>
</Button>
</Grid>
</Grid>
</Window>
Такое проектное решение без всякого труда позволит вам добавить и ViewModel, и Model и возможность использовать один и тот же DataContext на разных страницах.
Спасибо за внимание.
Автор: AdlibBeats