Bottom sheets are becoming increasingly more popular in modern apps including Facebook, Instagram and Microsoft Teams. A bottom sheet comes in three main forms including:
- Standard – the sheet remains visible while users can interact with the rest of the content on the screen.
- Modal – the sheet must be dismissed to interact with the rest of the content on the screen.
- Expanding – the sheet is able to be resized with ‘snap’ points often a low, middle and fullscreen view. The underlying content is also able to be interacted with by the user.
In this Xamarin how-to post, I will show you how you can create a control to display a modal bottom sheet.
The criteria:
- Pure Xamarin.Forms (no custom renderers)
- Change sheet content through binding
- Open sheet via button
- Close sheet by either swiping down or tapping outside of the sheet’s content
- Smooth + efficient
Start off by creating a blank ContentView class. We create the PanGesture on the ContentView rather than the frame itself. This is to stop jerkiness on Android due to the way that the OS handles translations.
1 2 3 4 5 6 7 8 9 10 11 12 |
using System; using Xamarin.Forms; namespace BottomSheet.Controls { public class PanContainer : ContentView { public PanContainer() { } } } |
The Control’s .XAML
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 |
<?xml version="1.0" encoding="UTF-8"?> <ContentView xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:controls="clr-namespace:BottomSheet.Controls" x:Class="BottomSheet.Controls.BottomSheetControl" x:Name="BottomSheetRef" InputTransparent="True"> <ContentView.Content> <Grid> <BoxView Color="Black" Opacity="0" InputTransparent="True" x:Name="Backdrop"> <BoxView.GestureRecognizers> <TapGestureRecognizer Tapped="TapGestureRecognizer_Tapped"/> </BoxView.GestureRecognizers> </BoxView> <controls:PanContainer x:Name="PanContainerRef" HeightRequest="{Binding SheetHeight, Source={x:Reference BottomSheetRef}}" VerticalOptions="End"> <controls:PanContainer.GestureRecognizers> <PanGestureRecognizer PanUpdated="PanGestureRecognizer_PanUpdated"/> </controls:PanContainer.GestureRecognizers> <Frame x:Name="Sheet" Content="{Binding SheetContent ,Source={x:Reference BottomSheetRef}}" HeightRequest="{Binding SheetHeight, Source={x:Reference BottomSheetRef}}" VerticalOptions="End" BackgroundColor="White" CornerRadius="5" HasShadow="False"> </Frame> </controls:PanContainer> </Grid> </ContentView.Content> </ContentView> |
The Control’s Code Behind
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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
using System; using System.Collections.Generic; using System.Threading.Tasks; using DarkIce.Toolkit.Core.Utilities; using Xamarin.Essentials; using Xamarin.Forms; namespace BottomSheet.Controls { public partial class BottomSheetControl : ContentView { #region Constructors & initialisation public BottomSheetControl() { InitializeComponent(); } protected override void OnBindingContextChanged() { try { base.OnBindingContextChanged(); PanContainerRef.Content.TranslationY = SheetHeight + 60; } catch (Exception ex) { ex.Log(); } } #endregion #region Properties public static BindableProperty SheetHeightProperty = BindableProperty.Create( nameof(SheetHeight), typeof(double), typeof(BottomSheetControl), defaultValue: default(double), defaultBindingMode: BindingMode.TwoWay); public double SheetHeight { get { return (double)GetValue(SheetHeightProperty); } set { SetValue(SheetHeightProperty, value); OnPropertyChanged(); } } public static BindableProperty SheetContentProperty = BindableProperty.Create( nameof(SheetContent), typeof(View), typeof(BottomSheetControl), defaultValue: default(View), defaultBindingMode: BindingMode.TwoWay); public View SheetContent { get { return (View)GetValue(SheetContentProperty); } set { SetValue(SheetContentProperty, value); OnPropertyChanged(); } } #endregion uint duration = 250; double openPosition = (DeviceInfo.Platform == DevicePlatform.Android) ? 20 : 60; double currentPosition = 0; public async void PanGestureRecognizer_PanUpdated(object sender, PanUpdatedEventArgs e) { try { if (e.StatusType == GestureStatus.Running) { currentPosition = e.TotalY; if (e.TotalY > 0) { PanContainerRef.Content.TranslationY = openPosition + e.TotalY; } } else if (e.StatusType == GestureStatus.Completed) { var threshold = SheetHeight * 0.55; if (currentPosition < threshold) { await OpenSheet(); } else { await CloseSheet(); } } } catch (Exception ex) { ex.Log(); } } public async Task OpenSheet() { try { await Task.WhenAll ( Backdrop.FadeTo(0.4, length: duration), Sheet.TranslateTo(0, openPosition, length: duration, easing: Easing.SinIn) ); BottomSheetRef.InputTransparent = Backdrop.InputTransparent = false; } catch (Exception ex) { ex.Log(); } } public async Task CloseSheet() { try { await Task.WhenAll ( Backdrop.FadeTo(0, length: duration), PanContainerRef.Content.TranslateTo(x: 0, y: SheetHeight + 60, length: duration, easing: Easing.SinIn) ); BottomSheetRef.InputTransparent = Backdrop.InputTransparent = true; } catch (Exception ex) { ex.Log(); } } private async void TapGestureRecognizer_Tapped(System.Object sender, System.EventArgs e) { try { await CloseSheet(); } catch (Exception ex) { ex.Log(); } } } } |
Consuming the Control
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 |
<?xml version="1.0" encoding="UTF-8"?> <ContentPage xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:controls="clr-namespace:BottomSheet.Controls" xmlns:viewmodel="clr-namespace:BottomSheet" x:Class="BottomSheet.MainPage"> <ContentPage.BindingContext> <viewmodel:MainViewModel/> </ContentPage.BindingContext> <ContentPage.Content> <Grid> <Grid HorizontalOptions="Center"> <Button Text="Tap Me!" Clicked="Button_Clicked" VerticalOptions="Center"/> </Grid> <controls:BottomSheetControl x:Name="Sheet" SheetHeight="300"> <controls:BottomSheetControl.SheetContent> <StackLayout> <OnPlatform x:TypeArguments="View"> <On Platform="iOS"> <Grid> <BoxView CornerRadius="2.5" BackgroundColor="Gray" HorizontalOptions="Center" VerticalOptions="Start" HeightRequest="5" WidthRequest="40"/> </Grid> </On> <On Platform="Android"> <Grid> <Image Source="chevrondown" HorizontalOptions="Center" VerticalOptions="Start" WidthRequest="40"/> </Grid> </On> </OnPlatform> <Label HorizontalTextAlignment="Center" Margin="0,20" Text="Welcome to Xamarin how to" FontSize="18"/> <Image Source="xhtlogo" HorizontalOptions="Center"/> </StackLayout> </controls:BottomSheetControl.SheetContent> </controls:BottomSheetControl> </Grid> </ContentPage.Content> </ContentPage> |
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 |
using System; using System.Collections.Generic; using DarkIce.Toolkit.Core.Utilities; using Xamarin.Forms; namespace BottomSheet { public partial class MainPage : ContentPage { public MainPage() { InitializeComponent(); } private async void Button_Clicked(System.Object sender, System.EventArgs e) { try { await Sheet.OpenSheet(); } catch (Exception ex) { ex.Log(); } } } } |
I know this looks like a lot of code but it is quite simple. The bottom sheet control uses a pan gesture recognizer and a couple simple animations (FadeTo and TranslateTo) to give the desired look and feel. This bottom sheet is very lightweight and takes a little performance hit. The content inside the frame could very easily be changed to collection view to be used as a toolbar / menu with the bindings set up accordingly. I hope this control can be used in your next project!
Full source code is available here
It would be nice to be able to drag up until a desired height percentage to show more content!!! But good stuff. There is no good implementation of this out there.
Hi Luis,
I would love this feature as well! I plan to create an expandable bottom sheet at some stage in the future when time permits.
This should be part of XCT. Hard to believe that it is not in the library. Would you do a pull request and include in XCT?
Genial. Gracias
How to open sheet from ViewModel ?
How to open sheet from ViewModel?