SkeletonView

Skeleton placeholders mimic the shape of content that is still loading, improving perceived performance compared to a spinner. Uno Toolkit generates the placeholders for you from the real layout of your content: you never declare skeleton shapes.

Three building blocks cover the different ways content can be loading:

Type Use it when How it works
SkeletonView The real content is already in the visual tree, bound to data that is still loading (or being refreshed). Wraps the content, hides it while IsLoading is true, and overlays one placeholder per visual element at its actual position. Empty lists can get placeholder rows from their ItemTemplate.
SkeletonPresenter The real content is not in the tree while loading, e.g. inside a loading slot or a ProgressTemplate. Instantiates a private, data-less copy of a DataTemplate and generates placeholders from it, stamping placeholder rows into empty lists.
Skeleton attached properties You need to tune generation. Ignore, Shape, PlaceholderCount.

For MVUX's FeedView, see FeedView.

xmlns:utu="using:Uno.Toolkit.UI"

How placeholders are generated

Understanding the generation rules helps you get the skeleton you want with little or no tuning.

  1. The visual tree is walked from the content root. Collapsed elements and elements marked with utu:Skeleton.Ignore="True" are skipped along with their subtree.
  2. Leaves get exactly one placeholder. A leaf is a TextBlock, an Image, any Shape (Ellipse, Rectangle, Path, ...), a button (ButtonBase), or an input control whose value is its own chrome: TextBox, PasswordBox, RichEditBox, AutoSuggestBox, ComboBox, NumberBox, Slider and the other RangeBase controls, ProgressRing, ToggleSwitch, DatePicker, TimePicker, CalendarDatePicker, CalendarView, RatingControl, ColorPicker. Leaves are not descended into, so a Button produces a single button-sized placeholder regardless of its content, and a TextBox one placeholder on every platform.
  3. Containers contribute their children, not themselves. Panels and borders produce no placeholder of their own. For content controls (e.g. ListViewItem, ContentControl, cards), only the content is walked; the control's template chrome (backgrounds, selection and focus visuals) is ignored. Content hidden by that chrome (e.g. a collapsed Expander) is not mirrored.
  4. Each placeholder matches the leaf's position and size within the control, clipped to what can be seen of it: a row scrolled half out of a list produces half a placeholder, a row realized ahead of the viewport produces none, and a circle that gets clipped becomes a rectangle. Scrolling viewports and explicit Clips are honored; the SkeletonView's own bounds always are.
  5. Leaves without a value yet fall back to their layout slot. An empty data-bound TextBlock or a source-less Image measures as (almost) nothing, so the space the layout granted it is used instead:
    • width: the slot's width (a stretched empty TextBlock produces a full-width line); when the slot gives no width either, a TextBlock uses a default line width of 100;
    • height: for a TextBlock, one text line derived from its FontSize; otherwise the slot's height. Give such elements an explicit size, alignment or MinWidth when you want a more realistic placeholder.
  6. Shape: an Ellipse produces a circle; every other leaf produces a rounded rectangle using PlaceholderCornerRadius. Force either with utu:Skeleton.Shape. A circle is sized to the smaller dimension and centered.
  7. Empty lists with utu:Skeleton.PlaceholderCount get placeholder rows generated from their ItemTemplate (see Empty lists). Other empty lists have nothing to mirror and produce no placeholders.

Placeholders are regenerated on every layout pass while loading (the content can change without resizing anything: rows arriving in a list, containers being recycled), and when an appearance property changes. A pass where nothing moved costs a walk of the content and nothing else.

While loading, the content stays in the tree, so layout and bindings keep working, but it is not hit-testable. Only the elements covered by a placeholder are hidden: everything else stays visible, including ignored elements and the backgrounds and borders of containers (for example a card keeps its frame, with placeholders inside). Covered elements are hidden by setting their Opacity to 0; their original Opacity (local value or binding) is restored once loaded, and whenever the SkeletonView leaves the visual tree.

SkeletonView

Wraps live content and replaces it visually with generated placeholders while loading.

Properties

Property Type Default Description
IsLoading bool true Whether the placeholders are shown instead of the content. Note the default: a SkeletonView starts in its loading state. Bind it to your busy flag, or set Source.
Source ILoadable null An ILoadable (such as an async command) whose IsExecuting state drives IsLoading. When set, IsLoading follows IsExecuting. IsExecutingChanged must be raised on the UI thread: wrap a source that completes elsewhere in a LoadableSource. See LoadingView for ILoadable implementations.
EnableShimmer bool true Whether the shimmer animation plays while loading. Set to false for static placeholders (e.g. to reduce motion).
ShimmerDuration Duration 0:0:1.5 Duration of one shimmer sweep across the control. Larger values produce a slower sweep.
SkeletonBackground Brush SkeletonViewBackground resource Fill brush of the placeholders.
ShimmerBrush Brush SkeletonViewShimmerBrush resource Brush of the highlight band swept across the placeholders. Typically a horizontal LinearGradientBrush fading in and out of transparency. Set to null to remove the band entirely.
PlaceholderCornerRadius CornerRadius SkeletonViewCornerRadius resource (4) Corner radius of rectangular placeholders. Circles are unaffected.

As a ContentControl, SkeletonView also accepts Content, ContentTemplate, HorizontalContentAlignment/VerticalContentAlignment (both Stretch by default), Background, Padding, etc.

Basic usage

Write the content once, as regular XAML bound to your data, and wrap it:

<utu:SkeletonView IsLoading="{Binding IsBusy}">
    <StackPanel Spacing="12">
        <StackPanel Orientation="Horizontal" Spacing="12">
            <Ellipse Width="48" Height="48" Fill="{ThemeResource SystemControlHighlightAccentBrush}" />
            <StackPanel VerticalAlignment="Center" Spacing="4">
                <TextBlock Text="{Binding Title}" Style="{StaticResource SubtitleTextBlockStyle}" />
                <TextBlock Text="{Binding Subtitle}" Style="{StaticResource CaptionTextBlockStyle}" />
            </StackPanel>
        </StackPanel>
        <TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
        <Button Content="Some action" />
    </StackPanel>
</utu:SkeletonView>

While IsBusy is true, this shows a circle, two text lines, a full-width text line for the (still empty) description, and a button-shaped placeholder.

Driven by an ILoadable

Instead of a flag, let any ILoadable (e.g. an async command) drive the loading state:

<Button Content="Reload" Command="{Binding LoadDataCommand}" />

<utu:SkeletonView Source="{Binding LoadDataCommand}">
    <TextBlock Text="{Binding Description}" TextWrapping="Wrap" />
</utu:SkeletonView>

Refreshing loaded content

Because placeholders are generated from the live layout, toggling IsLoading back on over content that is already loaded produces a skeleton that matches it exactly: the same number of list items, and text lines matching the length of the current text. This makes SkeletonView well suited to refresh scenarios.

Empty lists

On a first load, a list has no items yet, so there are no rows to mirror. Set utu:Skeleton.PlaceholderCount on the list (or on any ancestor) to get placeholder rows anyway. The list can be nested anywhere in the content:

<utu:SkeletonView Source="{Binding LoadPeopleCommand}">
    <Grid RowSpacing="8">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <TextBlock Text="Team" utu:Skeleton.Ignore="True" />
        <ListView Grid.Row="1"
                  utu:Skeleton.PlaceholderCount="3"
                  ItemsSource="{Binding People}"
                  ItemTemplate="{StaticResource PersonRowTemplate}" />
        <Button Grid.Row="2" Content="Invite someone" />
    </Grid>
</utu:SkeletonView>

While loading, every ItemsControl, ListView, GridView and ItemsRepeater in the content that has an ItemTemplate, no items, and a PlaceholderCount (set on itself or an ancestor, locally or through a Style):

  • gets PlaceholderCount rows built from a private copy of the list (same ItemTemplate, plus ItemContainerStyle, ItemsPanel, Style and Padding for an ItemsControl, or Layout for an ItemsRepeater) filled with null items, so bound values are empty and follow the empty-value rules;
  • reserves the rows' space when it is sized by its content (e.g. in an Auto row or a vertical StackPanel): its MinHeight is raised temporarily, up to its MaxHeight, so the content below it moves down as if the rows existed. A list that already has more space than it needs (e.g. stretched in a * row) keeps its size, and rows that don't fit are clipped. A list with an explicit Height is never resized;
  • goes back to normal once loading ends or its items arrive: the rows are dropped, its own items are mirrored instead, and its MinHeight (local value or binding) is restored.

The list's ItemsSource, selection and position in the tree are never changed.

Notes:

  • The list needs a width: a list that is 0 wide (e.g. unconstrained in a horizontal StackPanel) gets no rows.
  • A list's Header and Footer are not mirrored; placeholder rows start at the top of the list.
  • ItemTemplateSelector and ItemContainerStyleSelector are not used for placeholder rows (their items are null): set ItemTemplate, and ItemContainerStyle if needed. The template must tolerate a null data context.
  • Other ItemsControl-derived controls (ListBox, FlipView, custom subclasses of ItemsControl) don't get rows; a Debug log entry says so. Subclasses of ListView and GridView are treated as ListView and GridView: a Style targeting the subclass is not applied to the placeholder copy.

Appearance

Every appearance property can be set per instance:

<utu:SkeletonView IsLoading="{Binding IsBusy}"
                  SkeletonBackground="#D7E3F4"
                  PlaceholderCornerRadius="12"
                  ShimmerDuration="0:0:3">
    <utu:SkeletonView.ShimmerBrush>
        <LinearGradientBrush StartPoint="0,0.5" EndPoint="1,0.5">
            <GradientStop Offset="0" Color="#00FFFFFF" />
            <GradientStop Offset="0.5" Color="#AAFFFFFF" />
            <GradientStop Offset="1" Color="#00FFFFFF" />
        </LinearGradientBrush>
    </utu:SkeletonView.ShimmerBrush>
    <!-- content -->
</utu:SkeletonView>

<!-- Static placeholders, no animation -->
<utu:SkeletonView IsLoading="{Binding IsBusy}" EnableShimmer="False">
    <!-- content -->
</utu:SkeletonView>

To change the look app-wide, override the lightweight styling resources instead.

SkeletonPresenter

A SkeletonView that generates its skeleton from a DataTemplate instead of live content. Use it wherever the real content does not exist yet while loading: a LoadingView's LoadingContent, a hand-written ProgressTemplate, or a control template.

If the real content is in the tree while loading, and only its list is empty, you don't need a SkeletonPresenter: wrap the content in a SkeletonView and set utu:Skeleton.PlaceholderCount on the list (see Empty lists).

SkeletonPresenter derives from SkeletonView, so it inherits all of its properties and lightweight styling. IsLoading defaults to true, which is what you want for a loading slot. When the slot is hidden rather than removed (a LoadingView keeps its LoadingContent in the tree once loaded), the shimmer follows ProgressExtensions.IsActive like a ProgressRing would: LoadingView sets it on its loading slot, so nothing animates out of sight. Elsewhere, set IsLoading to false to stop it.

Template

The skeleton is derived from the presenter's ContentTemplate. In a control template, template-bind it to the control's value template (e.g. ContentTemplate="{TemplateBinding ValueTemplate}"). A presenter without a ContentTemplate (and without element Content) logs a warning when it loads.

The presenter's Content (if any) becomes the template's data context. Leave it empty, or pass an object whose data is not loaded yet, so that bound values are empty and placeholders follow the empty-value rules.

Placeholder rows for lists

A template typically contains a list whose items are not loaded yet. Every ItemsControl (including ListView/GridView) and ItemsRepeater in the instantiated template that has an ItemTemplate but no items and no ItemsSource receives utu:Skeleton.PlaceholderCount dummy items, so the list realizes placeholder rows from its own ItemTemplate. This only ever affects the presenter's private instantiation of its ContentTemplate, never your live content: elements set directly as the presenter's Content are treated like the content of any SkeletonView (an empty list in them gets rows through PlaceholderCount instead).

ItemTemplateSelector is not used for placeholder rows: set ItemTemplate.

Usage

<Page.Resources>
    <DataTemplate x:Key="PersonRowTemplate">
        <StackPanel Orientation="Horizontal" Spacing="12">
            <Ellipse Width="40" Height="40" Fill="{ThemeResource SystemControlHighlightAccentBrush}" />
            <StackPanel VerticalAlignment="Center" Spacing="4">
                <TextBlock Text="{Binding Name}" />
                <TextBlock Text="{Binding Title}" FontSize="12" />
            </StackPanel>
        </StackPanel>
    </DataTemplate>
</Page.Resources>

<utu:SkeletonPresenter utu:Skeleton.PlaceholderCount="3">
    <utu:SkeletonPresenter.ContentTemplate>
        <DataTemplate>
            <ListView ItemTemplate="{StaticResource PersonRowTemplate}" />
        </DataTemplate>
    </utu:SkeletonPresenter.ContentTemplate>
</utu:SkeletonPresenter>

Inside a LoadingView

Reuse the same item template for both the loaded list and its loading skeleton:

<utu:LoadingView Source="{Binding LoadPeopleCommand}">
    <ListView ItemsSource="{Binding People}" ItemTemplate="{StaticResource PersonRowTemplate}" />

    <utu:LoadingView.LoadingContent>
        <utu:SkeletonPresenter utu:Skeleton.PlaceholderCount="3">
            <utu:SkeletonPresenter.ContentTemplate>
                <DataTemplate>
                    <ListView ItemTemplate="{StaticResource PersonRowTemplate}" />
                </DataTemplate>
            </utu:SkeletonPresenter.ContentTemplate>
        </utu:SkeletonPresenter>
    </utu:LoadingView.LoadingContent>
</utu:LoadingView>

Skeleton attached properties

The static Skeleton class provides attached properties that tune generation.

Property Type Default Set on Description
Skeleton.Ignore bool false Any element inside the content Excludes the element and its subtree from generation: no placeholder is produced for it, and it stays visible while loading. Use it for static parts that do not depend on the data being loaded, such as headers or labels.
Skeleton.Shape SkeletonShape Auto Any element inside the content Forces the placeholder shape. Any value other than Auto also makes the element a leaf: a single placeholder covers it and its subtree is not visited. Use it to collapse a complex element (e.g. an avatar made of an Image inside a Border) into one shape.
Skeleton.PlaceholderCount int 0 In a SkeletonView: an empty list, or any of its ancestors. For a SkeletonPresenter: the presenter or any of its ancestors (typically the control whose template hosts the presenter) Number of placeholder rows generated for empty lists while loading. The nearest element with a value applies, whether set locally or through a Style; a 0 set locally disables rows, and vetoes an ancestor's value (a 0 from a Style is the same as no value). In a SkeletonView, rows are opt-in: a list without a value gets none. A SkeletonPresenter stamps its template's lists with the nearest value (the presenter itself first, then its ancestors), else SkeletonPresenter.DefaultPlaceholderCount (4).

SkeletonShape

Value Placeholder
Auto Inferred: an Ellipse produces a circle, every other leaf a rounded rectangle.
Rectangle A rectangle with PlaceholderCornerRadius, covering the element's bounds.
Circle A circle whose diameter is the smaller of the element's width and height, centered in its bounds.

Overrides

<utu:SkeletonView IsLoading="{Binding IsBusy}">
    <StackPanel Spacing="12">
        <!-- static header: stays visible while loading -->
        <TextBlock utu:Skeleton.Ignore="True" Text="Profile" />

        <!-- one circle instead of the image inside -->
        <Border utu:Skeleton.Shape="Circle" Width="64" Height="64" CornerRadius="32">
            <Image Source="{Binding AvatarUrl}" Stretch="UniformToFill" />
        </Border>

        <!-- one block instead of every chip inside -->
        <ItemsControl utu:Skeleton.Shape="Rectangle" Height="32" ItemsSource="{Binding Tags}" />
    </StackPanel>
</utu:SkeletonView>

FeedView (Uno Extensions)

MVUX's FeedView is supported through the SkeletonFeedViewStyle style provided by Uno.Extensions, which uses SkeletonPresenter for the initial load and SkeletonView for refreshes. See the FeedView documentation for how to apply it.

Lightweight Styling

Key Type Value
SkeletonViewBackground SolidColorBrush #14000000, black at 8% (Dark: #1AFFFFFF, white at 10%)
SkeletonViewShimmerBrush LinearGradientBrush Horizontal transparent → #A6FFFFFF → transparent (Dark: #1AFFFFFF)
SkeletonViewCornerRadius CornerRadius 4

The default brushes are translucent, so placeholders take on the surface they sit on (a card, a tinted page, or the Material and Cupertino backgrounds) without per-theme overrides.

These resources apply to both SkeletonView and SkeletonPresenter. Override them in your application resources to restyle every skeleton:

<ResourceDictionary.ThemeDictionaries>
    <ResourceDictionary x:Key="Light">
        <SolidColorBrush x:Key="SkeletonViewBackground" Color="#E8EEF6" />
    </ResourceDictionary>
    <ResourceDictionary x:Key="Dark">
        <SolidColorBrush x:Key="SkeletonViewBackground" Color="#2B3442" />
    </ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>

Limitations and troubleshooting

  • Nothing is shown while loading. The content has no leaves yet, or they are collapsed. For an empty list, set utu:Skeleton.PlaceholderCount on it (see Empty lists). For content that isn't in the tree at all while loading, use a SkeletonPresenter.
  • An empty list gets no rows. Check that it has an ItemTemplate and a width, and that PlaceholderCount is set on it or an ancestor and is greater than 0.
  • A placeholder covers a whole area. An element in the content is a Shape or button spanning that area (e.g. a decorative background Rectangle). Mark it with utu:Skeleton.Ignore="True" to keep it visible as-is instead.
  • Text placeholders are full width. An empty, horizontally stretched TextBlock falls back to its layout slot. Set HorizontalAlignment="Left" with a MinWidth, or a Width, for a shorter line.
  • An element's Opacity is changed while loading. Covered elements are hidden through Opacity, overriding values the app sets or animates on them during that time; the original value or binding is restored once loaded. Avoid driving Opacity of covered elements while loading.
  • The skeleton always shows. IsLoading defaults to true; bind it or set Source.
  • Elements outside the control are not covered. Placeholders are confined to the SkeletonView; wrap each area that loads.
  • Nested skeletons own their content. A SkeletonView (or SkeletonPresenter) inside the content is left alone: the outer view produces no placeholders for it, and each view hides and restores its own elements.
  • Keyboard focus can still reach covered content. Hidden elements are not hit-testable, but a Tab can still focus a covered control, as with LoadingView. Bind IsEnabled of such controls to your loading flag if that matters. The placeholders themselves are not exposed to assistive technologies.
  • Warnings in the log. Uno.Toolkit.UI.SkeletonPresenter warns when it has no ContentTemplate to derive the skeleton from. Lists skipped for an unsupported kind are logged at Debug level.
  • Virtualized lists only produce placeholders for realized items, which is usually what fills the viewport.

Samples

See the SkeletonView page of the Uno Toolkit sample app for a live demo of SkeletonView and SkeletonPresenter, including generation overrides, appearance, empty lists and LoadingView integration.