Material transition

This example shows how you can create a neat material design card animation. It is inspired by this awesome piece by Ivan Bjelajac.

The icons are from Google's Material Icons, and images from Unsplash.

Resources, data and components

Global, static resources such as fonts, text styles and the color palette are defined as a ux:Class in a separate file, StaticResources.ux. Since it only contains static resources (ux:Class and ux:Global) it doesn't need to be instantiated – its contents are automatically available from any other file in our project.

<Panel ux:Class="StaticResources">
    <Font ux:Global="RobotoMedium" File="Assets/Roboto-Medium.ttf" />

    <float4 ux:Global="PrimaryColor" ux:Value="#29B6F6" />
    <float4 ux:Global="AccentColor" ux:Value="#E91E63" />
    
    <Text ux:Class="SmallHeaderText" Font="RobotoMedium" FontSize="17" Color="PrimaryColor" Margin="0,15,0,0" />
    <Text ux:Class="BodyText" FontSize="14" Color="#263238" TextWrapping="Wrap" />
    <BodyText ux:Class="MutedText" Color="#888" />
</Panel>

Our made-up mock data comes from a JavaScript file which exports an array of events under the key events.

<JavaScript File="data.js" />

The app also contains a number of components whose internals aren't relevant to the effect this example focuses on, but mainly serve to mimic parts of a real-world app screen. To reduce clutter in our MainView.ux, these are defined in separate files under the Components/ directory.

Although this example demonstrates a navigation flow, we are going to use Selection and Selectable to handle the navigation between the list of cards and the expanded state. The reason is that we're not actually navigating between pages, but rather moving elements around to expose the content and fill the screen when a card is expanded. Selection fits this use case perfectly, since we can have a WhileSelected trigger for each card that animates that particular card to the expanded state. Additionally, we've configured our Selection to allow no more than one item to be selected at a time. This gives us the behavior of a two-state navigation system, where we can either have zero selected items (list of cards), or one selected item (expanded view of a particular card).

Below is the basic outer structure of the app.

<DockPanel>
    <StatusAndAppBar ux:Name="statusAndAppBar" Dock="Top" />
    
    <ScrollView ux:Name="mainScrollView" ClipToBounds="false" LayoutMode="PreserveVisual">
        <StackPanel Margin="0,0,0,15">
            <Selection MinCount="0" MaxCount="1" />
            
            <Each Items="{events}">
                <EventCard layoutTarget="contentPlaceholder">
                    <Selectable Value="{id}" />
                    <WhileSelected>
                        <Change mainScrollView.UserScroll="false" DelayBack="0" />
                        <Move Target="statusAndAppBar" Y="-1" RelativeTo="Size" Duration="1.2" Delay="0.1" Easing="QuarticOut" EasingBack="QuarticIn" />
                    </WhileSelected>
                </EventCard>
            </Each>
        </StackPanel>
    </ScrollView>
    
    <BottomBarBackground Dock="Bottom" />
</DockPanel>

The cards

In our mock dataset, we've given each item a unique id, which we bind to the Value of each card's Selectable. This is required by Selectable to identify what is currently selected.

We've also declared a ImageHeight property with a default value of 200. As we'll see next, this is done to reduce duplication, as we'll use this value twice.

Additionally, we have defined a ux:Dependency="layoutTarget" that requires us to pass in a reference to the contentPlaceholder Panel, so that we can change the LayoutMaster of the card contents to an element outside of the ux:Class.

<StackPanel ux:Class="EventCard" ImageHeight="200">
    <float ux:Property="ImageHeight" />
    <Panel ux:Dependency="layoutTarget" />

The image part of each card actually contains both the image and the expanded content. However, the DockPanel containing the image and content has ClipToBounds="true" and is wrapped in a panel whose Height is set to the ImageHeight property we defined earlier. This results in only the topmost 200pt of the imageAndContent panel being visible.

<Panel ux:Name="contentLimitPanel" Height="{ReadProperty ImageHeight}">
    <DockPanel ux:Name="imageAndContent" ClipToBounds="true" HitTestMode="LocalBoundsAndChildren">

We set the Height of the panel containing the image and title to the same ImageHeight property as contentLimitPanel. Now the image has the same height as contentLimitPanel and thus fits perfectly within the clipped area, rendering the expanded content invisible.

<Panel Dock="Top" Height="{ReadProperty ImageHeight}">

The transition

At the root of our app, we have defined an empty panel named contentPlaceholder and as shown before, we have passed it in as a ux:Dependency to all of our cards.

<Panel ux:Name="contentPlaceholder" />
<Each Items="{events}">
    <EventCard layoutTarget="contentPlaceholder">
<StackPanel ux:Class="EventCard" ImageHeight="200">
    <Panel ux:Dependency="layoutTarget" />

When a card is selected, we change the LayoutMaster of the panel containing the image and content to this layoutTarget. This results in contentLimitPanel no longer limiting the panel's height, and it can take up all the space inside contentPlaceholder, making the content visible. Since contentPlaceholder is placed at the root of our app, this will make it fill the whole screen.

<WhileSelected>
    <Change imageAndContent.LayoutMaster="layoutTarget" DelayBack="0" Delay="0" />

We'll add a LayoutAnimation to the imageAndContent panel to smoothly animate to the new position and size.

<LayoutAnimation>
    <Move X="1" RelativeTo="WorldPositionChange" DelayBack="0" Duration="0.2" Easing="QuadraticInOut" />
    <Move Y="1" RelativeTo="WorldPositionChange" DelayBack="0" Duration="0.2" Easing="SinusoidalIn" />
    <Resize X="1" Y="1" RelativeTo="SizeChange" DelayBack="0" Duration="0.25" Easing="QuadraticIn" />
</LayoutAnimation>

Next, we add a BringToFront to bring the card in front of all other cards.

Because the card is still rooted within the main ScrollView, the imageAndContent panel is still tied to the scroll position, even when we change its LayoutMaster. Thus, we immediately disable user interaction for this ScrollView, so that the user can't scroll the list of cards while a card is expanded.

Since the expanded content shouldn't have any margin at all, we can't just add a margin around the whole card. Instead, the same 15pt margin is applied to both the image and the card's background separately. This lets us animate the margin of only the image to to 0 during the transition. Because image has Layer="Background", this will not result in any significant performance impact.

The status bar and app bar are moved out of view as well.

It is important to note that we have added a WhileSelected trigger in two places - one in the Each loop outside of the card ux:Class, and another inside of of the ux:Class. This lets us have direct access to UX elements in global and local scopes, respectively. The instructions in both triggers get stacked and executed simultaneously.

<WhileSelected>
    <Change mainScrollView.UserScroll="false" DelayBack="0" />
    <Move Target="statusAndAppBar" Y="-1" RelativeTo="Size" Duration="1.2" Delay="0.1" Easing="QuarticOut" EasingBack="QuarticIn" />
</WhileSelected>
<WhileSelected>
    <Change imageAndContent.LayoutMaster="layoutTarget" DelayBack="0" Delay="0" />
    <BringToFront />
    
    <Change image.CornerRadius="0" Duration="0.1" DelayBack="0" />
    <Change image.Margin="0" Duration="0.25" Delay="0" DelayBack="0" Easing="CubicInOut" />
</WhileSelected>

Some parts of the transition make more sense to animate the other way, that is, use the active state of a trigger to hide an element instead of expanding or revealing it. Because of this, we have a separate inverted version of WhileSelected, which will be active while the card is not selected.

Most of these animations are small details such as scaling the plus button, fading out the content, etc. We also use it to fade out and disable hit tests for the detailNavigationBar, which contains the back button.

<WhileSelected Invert="true">
    <Change contentScrollView.Opacity="0.4" Duration="0.3" DelayBack="0" />
    <Change content.Opacity="0.5" Duration="0.5" DelayBack="0" />
    <Move Target="content" Y="30" Duration="0.7" DelayBack="0" Delay="0" Easing="QuadraticIn" />

    <Scale Target="plusButton" Factor="0.01" Delay="0" Duration="0.2" DelayBack="0.25" DurationBack="0.55" Easing="CubicInOut" />
    <Rotate Target="plusButton" Degrees="-90" Delay="0" Duration="0.5" DelayBack="0.25" DurationBack="1.1" EasingBack="CubicIn" />
    <Change plusButton.Opacity="0" Duration="0.2" DelayBack="0.2" DurationBack="0.5" />
    
    <Change detailNavigationBar.Opacity="0" Duration="0.2" Delay="0" />
    <Change detailNavigationBar.HitTestMode="None" />
</WhileSelected>

And that's it! Feel free download the code and play around.