Neuron®
The Neuron® is the basis for the creation of open and secure federated networks for smart societies.
Loading...
Searching...
No Matches
BottomSheetView.cs
1using CommunityToolkit.Maui.Core;
2using Microsoft.Maui.Controls.Shapes;
3
5{
6 public class BottomSheetView : Grid
7 {
8 // Constant fallback header height in case the header hasn't been measured yet.
9 private const double defaultHeaderHeight = 50;
10 private const uint animationDuration = 200;
11 private const double flickVelocityThreshold = 1;
12
13 // Layout elements.
14 private readonly Border cardBorder;
15 private readonly ContentView headerContainer;
16 private readonly ContentView contentPresenter;
17
18 // Fields for layout calculations.
19 private double sheetHeight;
20 private bool isExpanded = false;
21
22 public bool IsExpanded => this.isExpanded;
23
24 // Bindable property for the header background color (still available if needed).
25 public static readonly BindableProperty HeaderBackgroundColorProperty =
26 BindableProperty.Create(nameof(HeaderBackgroundColor), typeof(Color), typeof(BottomSheetView), Colors.LightGray);
27
28 public Color HeaderBackgroundColor
29 {
30 get => (Color)this.GetValue(HeaderBackgroundColorProperty);
31 set => this.SetValue(HeaderBackgroundColorProperty, value);
32 }
33
34 // Bindable property for controlling maximum expanded height.
35 public static readonly BindableProperty MaxExpandedHeightProperty =
36 BindableProperty.Create(nameof(MaxExpandedHeight), typeof(double), typeof(BottomSheetView), -1.0);
37
42 public double MaxExpandedHeight
43 {
44 get => (double)this.GetValue(MaxExpandedHeightProperty);
45 set => this.SetValue(MaxExpandedHeightProperty, value);
46 }
47
48 // Bindable property for the header content.
49 public static readonly BindableProperty HeaderContentProperty =
50 BindableProperty.Create(nameof(HeaderContent), typeof(View), typeof(BottomSheetView), default(View));
51
55 public View HeaderContent
56 {
57 get => (View)this.GetValue(HeaderContentProperty);
58 set => this.SetValue(HeaderContentProperty, value);
59 }
60
61 public BottomSheetView()
62 {
63 this.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
64 this.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
65
66 // Transparent background so underlying content can be seen if needed.
67 this.BackgroundColor = Colors.Transparent;
68
69 // Initialize the Border that holds the entire bottom sheet content
70 this.cardBorder = new Border
71 {
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
77 };
78
79 // Create a grid with two rows.
80 // Row 0 for the header is now Auto sized.
81 Grid SheetGrid = [];
82 SheetGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
83 SheetGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
84
85 SheetGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
86
87 // Header container: a ContentView whose Content is bound to HeaderContent.
88 this.headerContainer = new ContentView();
89 this.headerContainer.SetBinding(ContentView.ContentProperty, new Binding(nameof(this.HeaderContent), source: this));
90
91 // Add pan gesture to the header container.
92 PanGestureRecognizer PanGesture = new();
93 PanGesture.PanUpdated += this.OnPanUpdated;
94 this.headerContainer.GestureRecognizers.Add(PanGesture);
95
96 // Add tap gesture to toggle open/closed
97 TapGestureRecognizer TapGesture = new();
98 TapGesture.Tapped += this.OnHeaderTapped;
99 this.headerContainer.GestureRecognizers.Add(TapGesture);
100
101 // If no header is provided, use a default header with a grab handle.
102 this.HeaderContent ??= this.CreateDefaultHeaderContent();
103
104 // Create a placeholder for the main content.
105 this.contentPresenter = new ContentView();
106
107 // Build the view hierarchy: header (row 0) and main content (row 1).
108 SheetGrid.Add(this.headerContainer, 0, 0);
109 SheetGrid.Add(this.contentPresenter, 0, 1);
110 this.cardBorder.Content = SheetGrid;
111
112 this.Add(this.cardBorder);
113
114 // Update sheet height and set collapsed position when the frame size changes.
115 this.cardBorder.SizeChanged += this.OnFrameSizeChanged;
116 }
117
121 public View MainContent
122 {
123 get => this.contentPresenter.Content;
124 set => this.contentPresenter.Content = value;
125 }
126
130 private Grid CreateDefaultHeaderContent()
131 {
132 BoxView Grabber = new()
133 {
134 WidthRequest = 40,
135 HeightRequest = 4,
136 CornerRadius = 2,
137 Color = Colors.Gray,
138 HorizontalOptions = LayoutOptions.Center,
139 VerticalOptions = LayoutOptions.Center
140 };
141
142 // Wrap the grabber in a Grid (or other layout) so that it can size appropriately.
143 return new Grid
144 {
145 Padding = new Thickness(0, 16),
146 Children = { Grabber }
147 };
148 }
149
153 private void OnFrameSizeChanged(object? sender, EventArgs e)
154 {
155 double AllowedHeight = this.MaxExpandedHeight > 0 ? this.MaxExpandedHeight : this.GetAllowedHeight();
156
157 // If the allowed height is set, constrain the cardBorder height.
158 if (AllowedHeight > 0)
159 {
160 this.cardBorder.HeightRequest = AllowedHeight;
161 this.sheetHeight = AllowedHeight;
162 }
163 else
164 {
165 this.cardBorder.HeightRequest = -1;
166 this.sheetHeight = this.cardBorder.Height;
167 }
168
169 // Move to correct initial position
170 if (!this.isExpanded)
171 this.SetTranslationToCollapsed();
172 else
173 this.SetTranslationToExpanded();
174 }
175
179 private double GetAllowedHeight()
180 {
181 double AllowedHeight = 0;
182 VisualElement CurrentElement = this;
183
184 // Check if this controls height is constrained
185 if (this.lastHeightConstraint > 0)
186 return this.lastHeightConstraint;
187
188 // Adopt to parents height
189 while (CurrentElement.Parent is VisualElement Parent)
190 {
191 CurrentElement = Parent;
192 if (CurrentElement.Height > 0)
193 {
194 AllowedHeight = CurrentElement.Height;
195 break;
196 }
197 }
198
199 // Fallback to MainPage height if no valid parent height was found.
200 Page? MainPage = Application.Current?.Windows.Count > 0 ? Application.Current.Windows[0].Page : null;
201 if (AllowedHeight <= 0 && MainPage is not null)
202 {
203 AllowedHeight = MainPage.Height;
204 }
205
206 return AllowedHeight;
207 }
208
209 private double lastHeightConstraint;
210
214 protected override Size MeasureOverride(double widthConstraint, double heightConstraint)
215 {
216 this.lastHeightConstraint = heightConstraint;
217
218 // Let the Grid do its normal measuring
219 return base.MeasureOverride(widthConstraint, heightConstraint);
220 }
221
222 private double previousPanY = 0;
223 private double initialTranslationY = 0;
224
225 private DateTime panStartTime;
226
231
232
233 private void OnPanUpdated(object? sender, PanUpdatedEventArgs e)
234 {
235 switch (e.StatusType)
236 {
237 case GestureStatus.Started:
238 this.panStartTime = DateTime.UtcNow;
239 this.previousPanY = e.TotalY;
240 this.initialTranslationY = this.cardBorder.TranslationY;
241 break;
242
243 case GestureStatus.Running:
244 double DeltaY;
245
246 if (DeviceInfo.Platform == DevicePlatform.iOS)
247 {
248 // iOS: TotalY is cumulative, so we compute delta from last event.
249 DeltaY = e.TotalY - this.previousPanY;
250 this.previousPanY = e.TotalY;
251 }
252 else
253 {
254 // Android: TotalY is incremental (already a delta).
255 DeltaY = e.TotalY;
256 }
257
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;
263 break;
264
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;
271
272 if (CurrentY >= MidPoint)
273 {
274 this.AnimateToCollapsed();
275 this.isExpanded = false;
276 }
277 else
278 {
279 this.AnimateToExpanded();
280 this.isExpanded = true;
281 }
282 break;
283 }
284 }
285
290 private void OnHeaderTapped(object? sender, EventArgs e)
291 {
292 // If already expanded, collapse; otherwise expand fully.
293 this.FinalizeSheetPositionTranslation(this.isExpanded ? (flickVelocityThreshold + 1) : -(flickVelocityThreshold + 1));
294 }
295
299 private void AnimateToCollapsed()
300 {
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);
304 }
305
309 private void AnimateToExpanded()
310 {
311 this.cardBorder.TranslateToAsync(0, 0, animationDuration, Easing.SinOut);
312 }
313
317 private void SetTranslationToCollapsed()
318 {
319 double HeaderHeight = this.headerContainer.Height > 0 ? this.headerContainer.Height : defaultHeaderHeight;
320 double CollapsedY = this.sheetHeight - HeaderHeight;
321 this.cardBorder.TranslationY = CollapsedY;
322 }
323
327 private void SetTranslationToExpanded()
328 {
329 this.cardBorder.TranslationY = 0;
330 }
331
335 public void ToggleExpanded()
336 {
337 if (this.isExpanded)
338 {
339 this.AnimateToCollapsed();
340 this.isExpanded = false;
341 }
342 else
343 {
344 this.AnimateToExpanded();
345 this.isExpanded = true;
346 }
347 }
348
353 private void FinalizeSheetPositionTranslation(double velocity)
354 {
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;
359
360 if (Math.Abs(velocity) > flickVelocityThreshold)
361 {
362 if (velocity < 0)
363 {
364 this.AnimateToExpanded();
365 this.isExpanded = true;
366 }
367 else
368 {
369 this.AnimateToCollapsed();
370 this.isExpanded = false;
371 }
372 }
373 else
374 {
375 if (CurrentY >= MidPoint)
376 {
377 this.AnimateToCollapsed();
378 this.isExpanded = false;
379 }
380 else
381 {
382 this.AnimateToExpanded();
383 this.isExpanded = true;
384 }
385 }
386 }
387 }
388}
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.