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.

using System;
using Xamarin.Forms;

namespace BottomSheet.Controls
{
    public class PanContainer : ContentView
    {
        public PanContainer()
        {
        }
    }
}

The Control’s .XAML




    
        

            
                
                    
                
            

            
                
                    
                
                
                
            

        
    




 

The Control’s Code Behind

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




    
        
    

    
        

            
                

 

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