Skip to content

Commit e9bf0da

Browse files
authored
Visual fixes + schedule sticky headers (#36)
1 parent 709eef9 commit e9bf0da

File tree

11 files changed

+541
-184
lines changed

11 files changed

+541
-184
lines changed

src/Conference.Maui/App.xaml.cs

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,27 @@ protected override Window CreateWindow(IActivationState? activationState)
1717
var window = new Window(new AppShell());
1818

1919
// Initialize the data sync service when the app starts
20-
_ = Task.Run(async () =>
20+
// Use async fire-and-forget without Task.Run to avoid threading issues
21+
_ = InitializeDataSyncSafely();
22+
23+
return window;
24+
}
25+
26+
private async Task InitializeDataSyncSafely()
27+
{
28+
try
2129
{
22-
try
30+
// Add a small delay to ensure app is fully initialized
31+
await Task.Delay(100);
32+
33+
if (_dataSyncService != null)
2334
{
24-
if (_dataSyncService != null)
25-
{
26-
await _dataSyncService.InitializeAsync();
27-
}
35+
await _dataSyncService.InitializeAsync();
2836
}
29-
catch (Exception ex)
30-
{
31-
System.Diagnostics.Debug.WriteLine($"Failed to initialize data sync service: {ex.Message}");
32-
}
33-
});
34-
35-
return window;
37+
}
38+
catch (Exception ex)
39+
{
40+
System.Diagnostics.Debug.WriteLine($"Failed to initialize data sync service: {ex.Message}");
41+
}
3642
}
3743
}
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
using Conference.Maui.Models;
2+
using Microsoft.Maui.Controls;
3+
using System.ComponentModel;
4+
5+
namespace Conference.Maui.Controls;
6+
7+
public class StickyHeaderBehavior : Behavior<CollectionView>
8+
{
9+
public static readonly BindableProperty StickyHeaderTemplateProperty =
10+
BindableProperty.Create(nameof(StickyHeaderTemplate), typeof(DataTemplate), typeof(StickyHeaderBehavior));
11+
12+
public static readonly BindableProperty HeaderContainerProperty =
13+
BindableProperty.Create(nameof(HeaderContainer), typeof(ContentView), typeof(StickyHeaderBehavior));
14+
15+
public DataTemplate? StickyHeaderTemplate
16+
{
17+
get => (DataTemplate?)GetValue(StickyHeaderTemplateProperty);
18+
set => SetValue(StickyHeaderTemplateProperty, value);
19+
}
20+
21+
public ContentView? HeaderContainer
22+
{
23+
get => (ContentView?)GetValue(HeaderContainerProperty);
24+
set => SetValue(HeaderContainerProperty, value);
25+
}
26+
27+
private CollectionView? _associatedObject;
28+
private object? _currentStickyHeader;
29+
private List<object>? _cachedItems; // Cache the items list
30+
private bool _lastShouldShowSticky = false; // Track last state to avoid redundant updates
31+
private bool _isAnimating = false; // Prevent overlapping animations
32+
private readonly double _scrollThreshold = 10;
33+
34+
35+
protected override void OnAttachedTo(CollectionView bindable)
36+
{
37+
_associatedObject = bindable;
38+
bindable.Scrolled += OnScrolled;
39+
40+
// Cache items when attached and when items source changes
41+
if (bindable.ItemsSource != null)
42+
{
43+
_cachedItems = bindable.ItemsSource.Cast<object>().ToList();
44+
}
45+
46+
// Listen for ItemsSource changes to update cache
47+
bindable.PropertyChanged += OnCollectionViewPropertyChanged;
48+
49+
base.OnAttachedTo(bindable);
50+
}
51+
52+
protected override void OnDetachingFrom(CollectionView bindable)
53+
{
54+
if (_associatedObject != null)
55+
{
56+
_associatedObject.Scrolled -= OnScrolled;
57+
_associatedObject.PropertyChanged -= OnCollectionViewPropertyChanged;
58+
}
59+
_associatedObject = null;
60+
_cachedItems = null;
61+
base.OnDetachingFrom(bindable);
62+
}
63+
64+
private void OnCollectionViewPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
65+
{
66+
if (e.PropertyName == nameof(CollectionView.ItemsSource))
67+
{
68+
var collectionView = sender as CollectionView;
69+
if (collectionView?.ItemsSource != null)
70+
{
71+
_cachedItems = collectionView.ItemsSource.Cast<object>().ToList();
72+
}
73+
else
74+
{
75+
_cachedItems = null;
76+
}
77+
}
78+
}
79+
80+
private void OnScrolled(object? sender, ItemsViewScrolledEventArgs e)
81+
{
82+
if (_associatedObject?.ItemsSource == null || HeaderContainer == null || StickyHeaderTemplate == null || _cachedItems == null)
83+
return;
84+
85+
// Find the current header based on scroll position
86+
object? currentHeader = null;
87+
bool shouldShowSticky = true;
88+
89+
// Use FirstVisibleItemIndex to determine which header should be sticky
90+
int visibleIndex = (int)e.FirstVisibleItemIndex;
91+
92+
// Early exit if we're beyond the list
93+
if (visibleIndex >= _cachedItems.Count)
94+
return;
95+
96+
// Walk backwards from the current position to find the most recent header
97+
for (int i = visibleIndex; i >= 0; i--)
98+
{
99+
if (i < _cachedItems.Count && IsHeaderItem(_cachedItems[i]))
100+
{
101+
currentHeader = _cachedItems[i];
102+
103+
// If the header we found is the first visible item, don't show sticky
104+
if (i == visibleIndex && e.VerticalOffset <= _scrollThreshold) // Small threshold for scroll position
105+
{
106+
shouldShowSticky = false;
107+
}
108+
break;
109+
}
110+
}
111+
112+
// Hide sticky header if we're at the very top of the list
113+
if (e.VerticalOffset <= 0)
114+
{
115+
shouldShowSticky = false;
116+
}
117+
118+
// Only update if the state actually changed to avoid unnecessary work
119+
var newStickyHeader = shouldShowSticky ? currentHeader : null;
120+
if (newStickyHeader != _currentStickyHeader || shouldShowSticky != _lastShouldShowSticky)
121+
{
122+
var oldHeader = _currentStickyHeader;
123+
_currentStickyHeader = newStickyHeader;
124+
_lastShouldShowSticky = shouldShowSticky;
125+
126+
// Use animated update if both old and new headers exist (transition)
127+
if (oldHeader != null && newStickyHeader != null && oldHeader != newStickyHeader)
128+
{
129+
_ = UpdateStickyHeaderWithAnimation(oldHeader, newStickyHeader);
130+
}
131+
// Use fade animation when showing/hiding headers
132+
else if (oldHeader != null && newStickyHeader == null)
133+
{
134+
_ = FadeOutStickyHeader();
135+
}
136+
else if (oldHeader == null && newStickyHeader != null)
137+
{
138+
_ = FadeInStickyHeader();
139+
}
140+
else
141+
{
142+
UpdateStickyHeader();
143+
}
144+
}
145+
}
146+
147+
private void UpdateStickyHeader()
148+
{
149+
if (HeaderContainer == null || StickyHeaderTemplate == null)
150+
return;
151+
152+
if (_currentStickyHeader != null)
153+
{
154+
var headerContent = StickyHeaderTemplate.CreateContent() as View;
155+
if (headerContent != null)
156+
{
157+
headerContent.BindingContext = _currentStickyHeader;
158+
HeaderContainer.Content = headerContent;
159+
HeaderContainer.IsVisible = true;
160+
161+
// Reset any transforms from animations
162+
headerContent.TranslationY = 0;
163+
headerContent.Opacity = 1;
164+
}
165+
}
166+
else
167+
{
168+
HeaderContainer.Content = null;
169+
HeaderContainer.IsVisible = false;
170+
}
171+
}
172+
173+
private async Task UpdateStickyHeaderWithAnimation(object oldHeader, object newHeader)
174+
{
175+
if (HeaderContainer == null || StickyHeaderTemplate == null || _isAnimating)
176+
return;
177+
178+
_isAnimating = true;
179+
180+
try
181+
{
182+
// Add subtle haptic feedback when header changes
183+
try
184+
{
185+
#if ANDROID || IOS
186+
HapticFeedback.Default.Perform(HapticFeedbackType.Click);
187+
#endif
188+
}
189+
catch
190+
{
191+
// Ignore haptic feedback errors - not critical
192+
}
193+
194+
var currentContent = HeaderContainer.Content as View;
195+
196+
// Create the new header content
197+
var newHeaderContent = StickyHeaderTemplate.CreateContent() as View;
198+
if (newHeaderContent == null)
199+
{
200+
UpdateStickyHeader();
201+
return;
202+
}
203+
204+
newHeaderContent.BindingContext = newHeader;
205+
206+
// Set up initial state for new header (positioned below, hidden)
207+
newHeaderContent.TranslationY = HeaderContainer.Height > 0 ? HeaderContainer.Height : 50;
208+
newHeaderContent.Opacity = 0;
209+
210+
// If we have existing content, animate it out
211+
if (currentContent != null)
212+
{
213+
// Start the slide-out animation for the old header
214+
var slideOutTask = currentContent.TranslateTo(0, -(HeaderContainer.Height > 0 ? HeaderContainer.Height : 50), 200, Easing.CubicInOut);
215+
var fadeOutTask = currentContent.FadeTo(0, 150, Easing.CubicInOut);
216+
217+
// Set the new content while the old one animates out
218+
HeaderContainer.Content = newHeaderContent;
219+
HeaderContainer.IsVisible = true;
220+
221+
// Start the slide-in animation for the new header (slight delay for better effect)
222+
await Task.Delay(50);
223+
var slideInTask = newHeaderContent.TranslateTo(0, 0, 250, Easing.CubicOut);
224+
var fadeInTask = newHeaderContent.FadeTo(1, 200, Easing.CubicOut);
225+
226+
// Wait for all animations to complete
227+
await Task.WhenAll(slideOutTask, fadeOutTask, slideInTask, fadeInTask);
228+
}
229+
else
230+
{
231+
// No existing content, just slide in the new header
232+
HeaderContainer.Content = newHeaderContent;
233+
HeaderContainer.IsVisible = true;
234+
235+
await Task.WhenAll(
236+
newHeaderContent.TranslateTo(0, 0, 300, Easing.CubicOut),
237+
newHeaderContent.FadeTo(1, 250, Easing.CubicOut)
238+
);
239+
}
240+
}
241+
catch (Exception)
242+
{
243+
// Fallback to non-animated update if animation fails
244+
UpdateStickyHeader();
245+
}
246+
finally
247+
{
248+
_isAnimating = false;
249+
}
250+
}
251+
252+
private async Task FadeInStickyHeader()
253+
{
254+
if (HeaderContainer == null || StickyHeaderTemplate == null || _currentStickyHeader == null || _isAnimating)
255+
return;
256+
257+
_isAnimating = true;
258+
259+
try
260+
{
261+
var headerContent = StickyHeaderTemplate.CreateContent() as View;
262+
if (headerContent == null)
263+
{
264+
UpdateStickyHeader();
265+
return;
266+
}
267+
268+
headerContent.BindingContext = _currentStickyHeader;
269+
headerContent.Opacity = 0;
270+
headerContent.TranslationY = -20; // Start slightly above
271+
272+
HeaderContainer.Content = headerContent;
273+
HeaderContainer.IsVisible = true;
274+
275+
// Fade in with a subtle slide down
276+
await Task.WhenAll(
277+
headerContent.FadeTo(1, 300, Easing.CubicOut),
278+
headerContent.TranslateTo(0, 0, 300, Easing.CubicOut)
279+
);
280+
}
281+
catch (Exception)
282+
{
283+
UpdateStickyHeader();
284+
}
285+
finally
286+
{
287+
_isAnimating = false;
288+
}
289+
}
290+
291+
private async Task FadeOutStickyHeader()
292+
{
293+
if (HeaderContainer == null || _isAnimating)
294+
return;
295+
296+
_isAnimating = true;
297+
298+
try
299+
{
300+
var currentContent = HeaderContainer.Content as View;
301+
if (currentContent != null)
302+
{
303+
// Fade out with a subtle slide up
304+
await Task.WhenAll(
305+
currentContent.FadeTo(0, 250, Easing.CubicIn),
306+
currentContent.TranslateTo(0, -20, 250, Easing.CubicIn)
307+
);
308+
}
309+
310+
HeaderContainer.Content = null;
311+
HeaderContainer.IsVisible = false;
312+
}
313+
catch (Exception)
314+
{
315+
HeaderContainer.Content = null;
316+
HeaderContainer.IsVisible = false;
317+
}
318+
finally
319+
{
320+
_isAnimating = false;
321+
}
322+
}
323+
324+
private bool IsHeaderItem(object item)
325+
{
326+
return item is TimeHeader;
327+
}
328+
}

src/Conference.Maui/MauiProgram.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using CommunityToolkit.Maui;
2+
using Conference.Maui.Controls;
23
using Conference.Maui.Interfaces;
34
using Conference.Maui.Pages;
45
using Conference.Maui.Services;

0 commit comments

Comments
 (0)