1using CommunityToolkit.Maui.Core;
2using Microsoft.Maui.Controls.Shapes;
9 private const double defaultHeaderHeight = 50;
10 private const uint animationDuration = 200;
11 private const double flickVelocityThreshold = 1;
14 private readonly Border cardBorder;
15 private readonly ContentView headerContainer;
16 private readonly ContentView contentPresenter;
19 private double sheetHeight;
20 private bool isExpanded =
false;
22 public bool IsExpanded => this.isExpanded;
25 public static readonly BindableProperty HeaderBackgroundColorProperty =
26 BindableProperty.Create(nameof(HeaderBackgroundColor), typeof(Color), typeof(
BottomSheetView), Colors.LightGray);
28 public Color HeaderBackgroundColor
30 get => (Color)this.GetValue(HeaderBackgroundColorProperty);
31 set => this.SetValue(HeaderBackgroundColorProperty, value);
35 public static readonly BindableProperty MaxExpandedHeightProperty =
44 get => (double)this.GetValue(MaxExpandedHeightProperty);
45 set => this.SetValue(MaxExpandedHeightProperty, value);
49 public static readonly BindableProperty HeaderContentProperty =
57 get => (View)this.GetValue(HeaderContentProperty);
58 set => this.SetValue(HeaderContentProperty, value);
63 this.ColumnDefinitions.Add(
new ColumnDefinition { Width =
new GridLength(1, GridUnitType.Star) });
64 this.RowDefinitions.Add(
new RowDefinition { Height =
new GridLength(1, GridUnitType.Star) });
67 this.BackgroundColor = Colors.Transparent;
70 this.cardBorder =
new Border
72 Style = AppStyles.BottomBarBorder,
73 Margin =
new Thickness(0, 0, 0, 0),
74 Padding =
new Thickness(0, 0, 0, 0),
75 StrokeShape =
new RoundRectangle { CornerRadius =
new CornerRadius(16, 16, 0, 0) },
76 VerticalOptions = LayoutOptions.End
82 SheetGrid.RowDefinitions.Add(
new RowDefinition { Height = GridLength.Auto });
83 SheetGrid.RowDefinitions.Add(
new RowDefinition { Height =
new GridLength(1, GridUnitType.Star) });
85 SheetGrid.ColumnDefinitions.Add(
new ColumnDefinition { Width =
new GridLength(1, GridUnitType.Star) });
88 this.headerContainer =
new ContentView();
89 this.headerContainer.SetBinding(ContentView.ContentProperty,
new Binding(nameof(this.
HeaderContent), source:
this));
92 PanGestureRecognizer PanGesture =
new();
93 PanGesture.PanUpdated += this.OnPanUpdated;
94 this.headerContainer.GestureRecognizers.Add(PanGesture);
97 TapGestureRecognizer TapGesture =
new();
98 TapGesture.Tapped += this.OnHeaderTapped;
99 this.headerContainer.GestureRecognizers.Add(TapGesture);
102 this.HeaderContent ??= this.CreateDefaultHeaderContent();
105 this.contentPresenter =
new ContentView();
108 SheetGrid.Add(this.headerContainer, 0, 0);
109 SheetGrid.Add(this.contentPresenter, 0, 1);
110 this.cardBorder.Content = SheetGrid;
112 this.Add(this.cardBorder);
115 this.cardBorder.SizeChanged += this.OnFrameSizeChanged;
123 get => this.contentPresenter.Content;
124 set => this.contentPresenter.Content = value;
130 private Grid CreateDefaultHeaderContent()
132 BoxView Grabber =
new()
138 HorizontalOptions = LayoutOptions.Center,
139 VerticalOptions = LayoutOptions.Center
145 Padding =
new Thickness(0, 16),
146 Children = { Grabber }
153 private void OnFrameSizeChanged(
object? sender, EventArgs e)
155 double AllowedHeight = this.MaxExpandedHeight > 0 ? this.MaxExpandedHeight : this.GetAllowedHeight();
158 if (AllowedHeight > 0)
160 this.cardBorder.HeightRequest = AllowedHeight;
161 this.sheetHeight = AllowedHeight;
165 this.cardBorder.HeightRequest = -1;
166 this.sheetHeight = this.cardBorder.Height;
170 if (!this.isExpanded)
171 this.SetTranslationToCollapsed();
173 this.SetTranslationToExpanded();
179 private double GetAllowedHeight()
181 double AllowedHeight = 0;
182 VisualElement CurrentElement =
this;
185 if (this.lastHeightConstraint > 0)
186 return this.lastHeightConstraint;
189 while (CurrentElement.Parent is VisualElement Parent)
191 CurrentElement = Parent;
192 if (CurrentElement.Height > 0)
194 AllowedHeight = CurrentElement.Height;
200 Page? MainPage = Application.Current?.Windows.Count > 0 ? Application.Current.Windows[0].Page :
null;
201 if (AllowedHeight <= 0 && MainPage is not
null)
203 AllowedHeight = MainPage.Height;
206 return AllowedHeight;
209 private double lastHeightConstraint;
214 protected override Size
MeasureOverride(
double widthConstraint,
double heightConstraint)
216 this.lastHeightConstraint = heightConstraint;
219 return base.MeasureOverride(widthConstraint, heightConstraint);
222 private double previousPanY = 0;
223 private double initialTranslationY = 0;
225 private DateTime panStartTime;
233 private void OnPanUpdated(
object? sender, PanUpdatedEventArgs e)
235 switch (e.StatusType)
237 case GestureStatus.Started:
238 this.panStartTime = DateTime.UtcNow;
239 this.previousPanY = e.TotalY;
240 this.initialTranslationY = this.cardBorder.TranslationY;
243 case GestureStatus.Running:
246 if (DeviceInfo.Platform == DevicePlatform.iOS)
249 DeltaY = e.TotalY - this.previousPanY;
250 this.previousPanY = e.TotalY;
258 double TargetY = this.cardBorder.TranslationY + DeltaY;
259 double HeaderHeight = this.headerContainer.Height > 0 ? this.headerContainer.Height : defaultHeaderHeight;
260 double CollapsedY = this.sheetHeight - HeaderHeight;
261 TargetY = Math.Max(0, Math.Min(TargetY, CollapsedY));
262 this.cardBorder.TranslationY = TargetY;
265 case GestureStatus.Completed:
266 case GestureStatus.Canceled:
267 double HeaderHeightEnd = this.headerContainer.Height > 0 ? this.headerContainer.Height : defaultHeaderHeight;
268 double CollapsedYEnd = this.sheetHeight - HeaderHeightEnd;
269 double MidPoint = CollapsedYEnd / 2;
270 double CurrentY = this.cardBorder.TranslationY;
272 if (CurrentY >= MidPoint)
274 this.AnimateToCollapsed();
275 this.isExpanded =
false;
279 this.AnimateToExpanded();
280 this.isExpanded =
true;
290 private void OnHeaderTapped(
object? sender, EventArgs e)
293 this.FinalizeSheetPositionTranslation(this.isExpanded ? (flickVelocityThreshold + 1) : -(flickVelocityThreshold + 1));
299 private void AnimateToCollapsed()
301 double HeaderHeight = this.headerContainer.Height > 0 ? this.headerContainer.Height : defaultHeaderHeight;
302 double CollapsedY = this.sheetHeight - HeaderHeight;
303 this.cardBorder.TranslateToAsync(0, CollapsedY, animationDuration, Easing.SinOut);
309 private void AnimateToExpanded()
311 this.cardBorder.TranslateToAsync(0, 0, animationDuration, Easing.SinOut);
317 private void SetTranslationToCollapsed()
319 double HeaderHeight = this.headerContainer.Height > 0 ? this.headerContainer.Height : defaultHeaderHeight;
320 double CollapsedY = this.sheetHeight - HeaderHeight;
321 this.cardBorder.TranslationY = CollapsedY;
327 private void SetTranslationToExpanded()
329 this.cardBorder.TranslationY = 0;
339 this.AnimateToCollapsed();
340 this.isExpanded =
false;
344 this.AnimateToExpanded();
345 this.isExpanded =
true;
353 private void FinalizeSheetPositionTranslation(
double velocity)
355 double HeaderHeight = this.headerContainer.Height > 0 ? this.headerContainer.Height : defaultHeaderHeight;
356 double CollapsedY = this.sheetHeight - HeaderHeight;
357 double MidPoint = CollapsedY / 2;
358 double CurrentY = this.cardBorder.TranslationY;
360 if (Math.Abs(velocity) > flickVelocityThreshold)
364 this.AnimateToExpanded();
365 this.isExpanded =
true;
369 this.AnimateToCollapsed();
370 this.isExpanded =
false;
375 if (CurrentY >= MidPoint)
377 this.AnimateToCollapsed();
378 this.isExpanded =
false;
382 this.AnimateToExpanded();
383 this.isExpanded =
true;
void ToggleExpanded()
Toggles the expanded state of the current object.
override Size MeasureOverride(double widthConstraint, double heightConstraint)
Saves the last height constraint provided during measure pass. Used for calculating available height.
View MainContent
Gets or sets the main content of the bottom sheet.
double MaxExpandedHeight
If set (> 0), this value determines the maximum overall height (header + content)....
View HeaderContent
Gets or sets the header content of the bottom sheet.