Parallax is one of those effects that is dangerously easy to overuse in your apps, but equally, when done right, can add an extra “wow!” factor that elevates an otherwise flat app to give it some depth.
What is parallax?
Parallax is the term given when things in the foreground appear to move quicker than things in the background. It was a neat trick used in 16-bit games to give a sense of depth of a 3D environment, and has recently become a trend in web design. And perhaps most interestingly, it’s one of the methods used to estimate the distance of stars and other objects in space.
Getting started
Let’s start with a basic app with a CollectionView
. The app we will build is a simple superheroes information app, that shows a series of cards with a picture and some text. I’ve downloaded some images and placed them into the Resources/Images
folder, and I’ve installed the MVVM Community Toolkit and MAUI Bindable Property Generator to reduce boilerplate.
The code is available on GitHub, I won’t walk through the code here as it should be self-explanatory (and if not is outside the scope of this post, but if you want to learn more about this code you can check out the official documentation or my book .NET MAUI in Action). For convenience I’ve also added it below; it’s collapsed so click on Details
to unhide it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:ParallaxCollection.ViewModels;assembly=ParallaxCollection"
x:DataType="vm:MainViewModel"
x:Class="ParallaxCollection.MainPage">
<Grid>
<CollectionView ItemsSource="{Binding Heroes}"
VerticalOptions="Center"
x:Name="HeroesCollection">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="vm:HeroCardViewModel">
<controls:HeroCard Background="{Binding Background}"
HeroName="{Binding Name}"
SecretIdentity="{Binding SecretIdentity}"
HeroImage="{Binding HeroImage}"
HeroLogo="{Binding LogoImage}"/>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>
It’s just got a simple binding in the code behind:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using ParallaxCollection.ViewModels;
namespace ParallaxCollection;
public partial class MainPage : ContentPage
{
private MainViewModel _viewModel;
public MainPage()
{
InitializeComponent();
_viewModel = new MainViewModel();
BindingContext = _viewModel;
}
}
The ViewModel contains the collection of heroes and a method to seed the collection:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
using CommunityToolkit.Mvvm.ComponentModel;
using System.Collections.ObjectModel;
namespace ParallaxCollection.ViewModels;
public partial class MainViewModel : ObservableObject
{
public ObservableCollection<HeroCardViewModel> Heroes { get; set; } =
[
new HeroCardViewModel
{
Name = "Superman",
SecretIdentity = "Clark Kent",
HeroImage = "superman.png",
LogoImage = "superman_logo.png",
Background = Colors.Blue
},
new HeroCardViewModel
{
Name = "Batman",
SecretIdentity = "Bruce Wayne",
HeroImage = "batman.png",
LogoImage = "batman_logo.png",
Background = Colors.DarkGray
},
new HeroCardViewModel
{
Name = "Wonder Woman",
SecretIdentity = "Diana Prince",
HeroImage = "wonderwoman.png",
LogoImage = "wonderwoman_logo.png",
Background = Colors.Gold
},
new HeroCardViewModel
{
Name = "The Flash",
SecretIdentity = "Barry Allen",
HeroImage = "theflash.png",
LogoImage = "theflash_logo.png",
Background = Colors.Red
},
new HeroCardViewModel
{
Name = "Green Lantern",
SecretIdentity = "Hal Jordan",
HeroImage = "greenlantern.png",
LogoImage = "greenlantern_logo.png",
Background = Colors.Green
},
new HeroCardViewModel
{
Name = "Shazam",
SecretIdentity = "Billy Batson",
HeroImage = "shazam.png",
LogoImage = "shazam_logo.png",
Background = Colors.Red
}
];
}
You can see that it’s using a HeroCardViewModel
.
1
2
3
4
5
6
7
8
9
10
11
12
using ParallaxCollection.Models;
namespace ParallaxCollection.ViewModels;
public class HeroCardViewModel
{
public string Name { get; set; } = hero.Name;
public string SecretIdentity { get; set; } = hero.SecretIdentity;
public string HeroImage { get; set; } = hero.HeroImage;
public string LogoImage { get; set; } = hero.LogoImage;
public Color Background { get; set; } = hero.Background;
}
Finally the HeroCard
view itself which is used as the data template in the collection:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<?xml version="1.0" encoding="utf-8" ?>
<ContentView x:Class="ParallaxCollection.Controls.HeroCard"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Grid>
<Border x:Name="Card"
Margin="30"
StrokeShape="RoundRectangle 30">
<Border.Shadow>
<Shadow Brush="Black"
Offset="20,20"
Radius="40"
Opacity="0.8" />
</Border.Shadow>
<Grid Padding="20"
ColumnDefinitions="2*,3*"
RowDefinitions="4*,*">
<Image x:Name="HeroLogoImage"
Grid.Row="1"
Grid.Column="0"
VerticalOptions="Center"
WidthRequest="75">
<Image.Shadow>
<Shadow Brush="Black"
Offset="20,20"
Radius="40"
Opacity="0.8" />
</Image.Shadow>
</Image>
<VerticalStackLayout Grid.Row="1"
Grid.Column="1"
VerticalOptions="Center">
<Label x:Name="HeroNameLabel"
Margin="10,0,0,0"
FontAttributes="Bold"
FontSize="Title"
TextColor="White" />
<Label x:Name="SecretIdentityLabel"
Margin="10,0,0,0"
FontSize="Body"
TextColor="White" />
</VerticalStackLayout>
</Grid>
</Border>
<Image x:Name="HeroImageImage"
VerticalOptions="Center"
HorizontalOptions="Center"
Aspect="AspectFit"
HeightRequest="400" />
</Grid>
</ContentView>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
using Maui.BindableProperty.Generator.Core;
namespace ParallaxCollection.Controls;
public partial class HeroCard : ContentView
{
public HeroCard()
{
InitializeComponent();
BindingContext = this;
}
[AutoBindable(OnChanged = nameof(heroNameChanged))]
private string heroName;
private void heroNameChanged(string value)
{
HeroNameLabel.Text = value;
}
[AutoBindable(OnChanged = nameof(secretIdentityChanged))]
private string secretIdentity;
private void secretIdentityChanged(string value)
{
SecretIdentityLabel.Text = value;
}
[AutoBindable(OnChanged = nameof(heroLogoChanged))]
private string heroLogo;
private void heroLogoChanged(string value)
{
HeroLogoImage.Source = value;
}
[AutoBindable(OnChanged = nameof(heroImageChanged))]
private string heroImage;
private void heroImageChanged(string value)
{
HeroImageImage.Source = value;
}
[AutoBindable(OnChanged = nameof(backgroundChanged))]
private Color background;
private void backgroundChanged(Color value)
{
Card.Stroke = new SolidColorBrush(value);
Card.BackgroundColor = value;
}
}
If you run and build the app now, you should see something like this:
The app with the standard CollectionView
You can see I’ve added a shadow here which already adds a little depth, but we can add a bit more by adding a parallax effect.
The logic
Before we get into the code, let’s briefly discuss the logic of how this effect will work. We’re going to manipulate the position of the images relative to the card, so that as we scroll, it appears the images are moving faster than the cards. This makes the image appear closer than the background, which creates the illusion of depth. To achieve this, every time the OnScrolled
event of the CollectionView
is fired, we’ll check the center of the image and adjust the Y
(vertical) position proportionally to the difference between the image’s center and the screen’s center. The further from the center of the screen it is, the more we’ll offset it.
As the center of the view moves further from the center of the screen, the vertical position of the image is offset from the background proportionally, giving it the sense that it is moving quicker and appears closer
Adding the controls
I’m going to create two custom controls - a ParallaxItemView
, which will expose a method that can be called when the CollectionView
is scrolled, and a ParallaxCollectionView
, which will call this event on its children when a scroll occurs.
As the implementation of this effect is going to be different for each platform, I’m going to use partial classes, with the implementations in the relevant Platforms
folders. For now the ParallaxItemView
will just outline the required functionality. In a folder called Controls
, I’ve added a file called ParallaxItemView.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
namespace ParallaxCollection.Controls;
public abstract partial class ParallaxItemView : ContentView
{
private int _platformY;
private int _denominator;
protected double ParallaxOffsetY;
private double ThisCenter;
private double CenterY;
public ParallaxItemView()
{
ConfigurePlatform();
}
public virtual void OnScrolled()
{
if (Height == -1)
return;
CalculateCentre();
var diff = ThisCenter - CenterY;
ParallaxOffsetY = diff / _denominator;
}
partial void ConfigurePlatform();
partial void CalculateCentre();
}
Let’s talk through the variables in this class:
_platformY
: This will represent the current vertical position of the view.ThisCenter
: This is going to represent the center of the item which we are offsetting.CenterY
: This represents the center of the screen.ParallaxOffsetY
: This is the main property we are going to manipulate to control the vertical position of the parallax item. Based on how farThisCenter
is fromCenterY
, we’re going to adjust this to offset the item and create the parallax effect.
Let’s also talk about the methods (and method declarations):
ConfigurePlatform
: This is called from the constructor and will set up the necessary platform specific pieces to make this work.CalculateCenter
: This will be called to calculate where on the screen the center of the item is, so that we know how far from the center of the screen it is and consequently how much it should be offset.OnScrolled
: This will be called when the item is scrolled so that the offset can be calculated. It’svirtual
because it will need to be overridden, so that the child class can apply theParallaxOffsetY
to whatever view it needs to after the calculations have completed.
Note also the _denominator
field. This is used as a scaling factor to control the extent of the effect. It will be set in the ConfigurePlatform
method as it will be different on each platform, but we could make this a configurable value to make the pronouncement of the effect controllable.
With the ParallaxItemView
done, the next thing is to create the ParallaxCollectionView
, also in the Controls
folder:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
namespace ParallaxCollection.Controls;
public class ParallaxCollectionView : CollectionView
{
protected override void OnScrolled(ItemsViewScrolledEventArgs e)
{
base.OnScrolled(e);
var vte = (IVisualTreeElement)this;
var visualItems = vte.GetVisualChildren();
foreach (var item in visualItems)
{
if (item is ParallaxItemView parallaxItem)
{
parallaxItem.OnScrolled();
}
}
}
}
This is fairly straightforward. It subclasses CollectionView
and overrides the OnScrolled
method. Every time the CollectionView
is scrolled, it will cast itself to IVisualTreeElement
so that it can get the child views. Then, each of these is checked to see if it’s an instance of ParallaxItemView
, and if so, the OnScrolled
method is called.
That’s all the shared functionality complete, so with that out the way, we can take a look at the platform specifics.
Android
In the Platforms/Android
folder, create a folder called Controls
, and in here, add the partial implementation of the ParallaxItemView
. I’ve put mine in a file called ParallaxItemView.Android.cs
, but it’s important to ensure it uses the same class name and namespace:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using Microsoft.Maui.Handlers;
namespace ParallaxCollection.Controls;
public partial class ParallaxItemView
{
partial void ConfigurePlatform()
{
}
partial void CalculateCentre()
{
ThisCenter = _platformY + (Height / 2);
}
}
You can see here that we’ve started with partial implementations of the methods defined in the shared class. The CalculateCenter
method is easy - it calculates the value for the center of the view as the _platformY
position plus half the height of the view. The Y position is the distance from the top of the screen to the top of the view, so adding half the height of the view gives us the center of the view.
_platformY is the distance from the top of the screen to the top of the view, and ThisCenter (the center of the view) is calculated by adding half the height of the view
Now we need to flesh out the ConfigurePlatform
method, so let’s start with two easy things: setting the values for _denominator
and CenterY
.
1
2
3
4
5
6
partial void ConfigurePlatform()
{
_denominator = 10;
CenterY = DeviceDisplay.MainDisplayInfo.Height / DeviceDisplay.MainDisplayInfo.Density / 2;
}
These are straightforward - _denominator
is an arbitrary value (worked out in my case by trial and error, but could be whatever works for you on Android). We’ll set a different value for iOS as it behaves differently, and talk about it a little more in the Challenges section at the end.
The next line sets the CenterY
value, which represents the center of the screen, using the cross-platform DeviceDisplay.MainDisplayInfo
API from .NET MAUI (see the docs) to get the height and density of the screen. Height
gives the total pixels, and dividing it by the Density
(pixels per unit), gets the height of the display in device independent units (DIUs).
The last step is to get the position of the view to set the _platformY
value. Unfortunately we can’t just use the Y
property of the view, as this will be relative to the parent (in this case the PrallaxCollectionView
) and not the screen. But as the view is derived from ContentView
, which maps to the View
type on Android, there are some useful platform APIs we can use instead. First, we can use the GetLocationOnScreen
method to get the view’s position relative to the screen. And second, we can use the ViewTreeObserver
to subscribe to the ScrollChanged
event, and update _platformY
whenever the position changes.
GetLocationOnScreen
returns an array of int
with two values - one for x
and one for y
. So all we need to do is grab the y
value and set _platformY
. You can see the full code for the ConfigurePlatform
method here:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
partial void ConfigurePlatform()
{
_denominator = 10;
CenterY = DeviceDisplay.MainDisplayInfo.Height / DeviceDisplay.MainDisplayInfo.Density / 2;
ContentViewHandler.Mapper.AppendToMapping("parallax", (handler, view) =>
{
if (view is ParallaxItemView pView)
{
handler.PlatformView.ViewTreeObserver!.ScrollChanged += (s, e) =>
{
int[] location = new int[2];
handler.PlatformView.GetLocationOnScreen(location);
int y = location[1];
pView._platformY = y;
};
}
});
}
The CalculateCenter
and OnScrolled
methods already do the rest of the work - once we’ve got the correct value for _platformY
, we call CalculateCenter()
to get the correct value for the center. And then the rest of the OnScrolled
method calculates and sets the OffsetParallaxY
value.
Which means that this is all the code we need on Android, so with that done we can move on to iOS.
iOS
iOS works a little differently than Android, but we can start the same way but adding the partial implementation to the Platforms/iOS/Controls
folder:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using Microsoft.Maui.Handlers;
namespace ParallaxCollection.Controls;
public partial class ParallaxItemView
{
partial void ConfigurePlatform()
{
_denominator = 3;
CenterY = 160;
}
partial void CalculateCentre()
{
}
}
Be sure to use the same namespace and class name. In the ConfigurePlatform
method, we’ve set a value for _denominator
, but rather than obtaining the center of the screen programmatically, we’re using a fixed value. For iOS, this is much easier than trying to get the value from the API, and iOS provides a fixed set of device specific resolutions (in points or DIUs). You can read more about these in the documentation, although I found a nice write-up here too.
To improve this, we would want to get the device model and set this accordingly (see Challenges section below), but for now this works for our limited use case.
The next step is to obtain the position of the view. This is not as straightforward as on Android; on iOS, ContentView
maps to UIView
, and its position is only ever relative to the parent view, and not the screen. But we can use the iOS API to obtain the position on-screen using the ConvertPointToView
method. ConvertPointToView
(on the UIView
class) returns a CGPoint
which contains X
and Y
values (CGPoint
can be considered similar to a Vector2
in .NET, although they are conceptually different).
To use this, we need to cast the view to the native UIView
, then call the ConvertPointToView
method, passing the view’s location bounds (which is also a CGPoint
) as a parameter. This will convert the position from the view’s coordinate system to the screen’s coordinate system. From here we can get the Y
value, and divide it by the density to get the position in DIUs.
The complete code for the iOS implementation is here:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using CoreGraphics;
using UIKit;
namespace ParallaxCollection.Controls;
public partial class ParallaxItemView
{
private readonly double _density = DeviceDisplay.MainDisplayInfo.Density;
partial void ConfigurePlatform()
{
_denominator = 3;
CenterY = DeviceDisplay.MainDisplayInfo.Height / DeviceDisplay.MainDisplayInfo.Density / 2;
}
partial void CalculateCentre()
{
CalculatePosition();
ThisCenter = _platformY + (Height / 2);
}
private void CalculatePosition()
{
var location = new CGPoint();
if (this.Handler?.PlatformView is UIView platformView)
{
location = platformView.ConvertPointToView(platformView.Bounds.Location, null);
}
_platformY = (int)(location.Y / _density);
}
}
This completes the implementation, so with both the iOS and Android implementations complete, we can update our UI to use the new controls.
Result
Now that we’ve got an implementation for ParallaxItemVeiw
on iOS and Android, we can update the hero card to inherit this instead of ContentView
directly, and override the OnScrolled
method to adjust the position of the image.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using Maui.BindableProperty.Generator.Core;
namespace ParallaxCollection.Controls;
public partial class HeroCard : ParallaxItemView
{
public HeroCard()
{
InitializeComponent();
BindingContext = this;
}
public override void OnScrolled()
{
base.OnScrolled();
HeroImageImage.TranslationY = ParallaxOffsetY;
}
/// ..
}
Remember that the ParallaxCollectionView
will check all its children to see if they are an instance of ParallaxItemView
, and if they are will call this method. The platform implementations we’ve just created will set a value for ParallaxOffsetY
when we call the base
method, and here assign that offset to the HeroImageImage
(note there’s a HeroLogoImage
and a HeroImageImage
- we could apply an offset to both - see Challenges section).
All that remains is to replace the CollectionView
in MainPage
with our new ParallaxCollectionView
. As ParallaxCollectionView
subclasses CollectionView
, it’s just a straight swap.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:ParallaxCollection.ViewModels;assembly=ParallaxCollection"
xmlns:controls="clr-namespace:ParallaxCollection.Controls;assembly=ParallaxCollection"
x:DataType="vm:MainViewModel"
x:Class="ParallaxCollection.MainPage">
<Grid>
<controls:ParallaxCollectionView ItemsSource="{Binding Heroes}"
VerticalOptions="Center"
x:Name="HeroesCollection">
<controls:ParallaxCollectionView.ItemTemplate>
<DataTemplate x:DataType="vm:HeroCardViewModel">
<controls:HeroCard Background="{Binding Background}"
HeroName="{Binding Name}"
SecretIdentity="{Binding SecretIdentity}"
HeroImage="{Binding HeroImage}"
HeroLogo="{Binding LogoImage}"/>
</DataTemplate>
</controls:ParallaxCollectionView.ItemTemplate>
</controls:ParallaxCollectionView>
</Grid>
</ContentPage>
Note
It’s actually not necessary to usecontrols:ParallaxCollectionView
inside the view (i.e. for theItemTemplate
as I’ve done here).CollectionView
works fine too, but I think it helps keep it more consistent to do it this way.
The changes here are minimal - the ParallaxItemView
and ParallaxCollectionView
do all the heavy lifting, so all we’ve done here is include the controls
namespace and switch the references from CollectionView
to ParallaxCollectionView
(we didn’t have to change anything on HeroCard
because we’ve already updated it to inherit ParallaxItemView
).
If we run the app now, we see the scrolling effect we’re after:
The parallax effect on Android
Challenges
This is a neat effect that we can use (while being careful not to overuse) to add some depth to our apps. This is very much a demo though; if you wanted to use this in a production app, there are some improvements you should consider. If you like this and want to take it a little further, here are some challenges you can take on to take this to the next level.
- Other OSes: This currently only works on iOS and Android. See if you can get it working on macOS and Windows.
- Shadows: To add an extra dimension to the depth, we could create a shadow of the image and insert it as a layer between the image and the card.
- Configurable offset: With this approach, if I wanted to adjust the offset, I would use a modifier on the
ParallaxOffsetY
(more on this below), but we could consider making denominator configurable. - Multiple layers: Right now there are two layers - the foreground and the background. We could add more - an easy way would be to add a modifier to
ParallaxOffsetY
before applying it to a view, a harder way would be to make it reusable by adding some kind of layer property to theParallaxItemView
which already has the modifier. This could result in unnecessary calculations if the layers aren’t in use, but the bigger challenge for us as developers is more likely keeping the effect subtle. When implementing a cool effect like this, it’s tempting to make it pronounced so the whole world can see, but for something like this it’s important to keep it barely noticeable. If in doubt, consult with your friendly neighbourhood UI expert 😉. - Better handling for
CenterY
on iPhone: This is currently using an arbitrary value, but we could get the device model and retrieve the center from a dictionary of known pixel heights for each device. - Find and implement your own: Find and implement a cool design on Dribble, or replicate the effect from another app you like.