MAUI UI July Day 17: Scrolling Label
Hello MAUI friends!
MAUI UI July Day 17: Scrolling Label
Hello MAUI friends!
I’m Calin, and today is day 17 of #MAUIUIJuly. I want to tackle a UI challenge that we’ve all faced: what do you do when your text is just… too long?
You have a beautiful, clean UI, but the song title, the product name, or the user’s address just doesn’t fit. The dreaded ellipsis (...) appears, hiding valuable information. What if we could make that text scroll, like a classic news ticker or a music player?

Example of a music player title text horizontally scrolling
Well, today, I want to show you how I did just that with a custom **ScrollingLabel **component!
Introducing the ScrollingLabel
At its core, the goal was simple: create a **Label that automatically starts scrolling horizontally if its content is too long to fit. From a developer's perspective, it should be as easy to use as a standard `Label`**.

Here’s how you can use it in your XAML:
<controls:ScrollingLabel
Text="{Binding CurrentSong.Title}"
FontSize="24"
FontAttributes="Bold"
TextColor="Black" />
Just drop it in, bind your text, and you’re good to go! If the text fits, it behaves like a normal label. If it overflows, the magic begins.
The Magic Behind the Scroll
The solution is split into a few key parts that work together to create the seamless scrolling effect, especially on Android where the most custom logic lives.
1. The Two-Part Control: ScrollingLabel and ScrollingLabelInternal
The control is actually made of two pieces. The **ScrollingLabel that you use in your XAML is a `Grid` **that contains the actual scrolling label and adds some nice fade effects on the sides so the text appears to slide in and out smoothly.
<Grid xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:CodeTest.Controls"
x:Class="CodeTest.Controls.ScrollingLabel"
x:Name="this">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="0.001" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<BoxView x:Name="fadeLeft"
IsVisible="False"
Grid.Column="0">
<BoxView.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="1,0">
<GradientStop Color="{Binding Source={x:Reference this}, Path=GradientColor}"
Offset="0.1" />
<GradientStop Color="#00FFFFFF"
Offset="1.0" />
</LinearGradientBrush>
</BoxView.Background>
</BoxView>
<controls:ScrollingLabelInternal x:Name="lblText"
Grid.Column="1"
VerticalOptions="Center"
ScrollText="{Binding Source={x:Reference this}, Path=Text}"
FontSize="{Binding Source={x:Reference this}, Path=FontSize}"
TextColor="{Binding Source={x:Reference this}, Path=TextColor}"
FontAttributes="{Binding Source={x:Reference this}, Path=FontAttributes}" />
<BoxView x:Name="fadeRight"
IsVisible="False"
Grid.Column="1"
HorizontalOptions="End">
<BoxView.Background>
<LinearGradientBrush StartPoint="0,0"
EndPoint="1,0">
<GradientStop Color="#00FFFFFF"
Offset="0.1" />
<GradientStop Color="{Binding Source={x:Reference this}, Path=GradientColor}"
Offset="1.0" />
</LinearGradientBrush>
</BoxView.Background>
</BoxView>
</Grid>
The real engine is the **ScrollingLabelInternal. This control inherits from the standard MAUI `Label**and contains all the logic for the animation. It has a specialScrollText` property which, when set, kicks off a **Task to handle the animation. The animation runs in a loop on a background thread, continuously updating the `Text` **property of the label to create the scrolling effect.
// Inside ScrollingLabelInternal.cs
public class ScrollingLabelInternal : Label
{
public static readonly BindableProperty ScrollTextProperty =
BindableProperty.Create(propertyName: nameof(ScrollText),
returnType: typeof(string),
declaringType: typeof(ScrollingLabelInternal),
defaultValue: null,
defaultBindingMode: BindingMode.OneWay,
propertyChanging: OnScrollTextChanged);
public string ScrollText
{
get { return (string)GetValue(ScrollTextProperty); }
set { SetValue(ScrollTextProperty, value); }
}
private static void OnScrollTextChanged(BindableObject pObj, object pOldVal, object pNewVal)
{
if (pObj is ScrollingLabelInternal scrollingLabelInternal)
{
scrollingLabelInternal.Text = pNewVal as string;
}
}
public string FullText { private set; get; }
string fullText = null;
Task taskScroll = null;
CancellationTokenSource tokenSource2 = null;
CancellationToken ct = CancellationToken.None;
public void StartScroll(bool hasEllipsis)
{
if (StopScroll())
{
tokenSource2 = new CancellationTokenSource();
ct = tokenSource2.Token;
if (hasEllipsis)
{
try
{
double LengthOfThreeDotsByFontSize = (CodeTest.Platforms.Android.Services.DisplayInfoService.MeasureTextSize(" ...", this.FontSize, this.FontFamily)).Width;
this.Dispatcher.Dispatch(() => (this.Parent as ScrollingLabel)?.ShowFade(true, LengthOfThreeDotsByFontSize) );
}
catch (Exception ex)
{
throw new InvalidOperationException("ScrollingLabelInternal failed to start scrolling. Ensure it is a direct child of a ScrollingLabel control and that the UI is in a valid state.", ex);
}
FullText = this.ScrollText;
fullText = this.ScrollText;
fullText += new string(' ', 20);
taskScroll = new Task(() =>
{
try
{
ScrollTextFunc(fullText);
}
catch { }
}, tokenSource2.Token);
taskScroll.Start();
}
else
{
try
{
this.Dispatcher.Dispatch(() =>
{
this.Text = ScrollText;
(this.Parent as Controls.ScrollingLabel)?.ShowFade(false, 0);
});
}
catch (Exception ex)
{
Console.WriteLine($"ScrollingLabelInternal StartScroll error {ex.Message}");
throw new InvalidOperationException("ScrollingLabelInternal must be a direct child of a ScrollingLabel control.", ex); }
}
}
}
void ScrollTextFunc(string fullText)
{
if (ct.IsCancellationRequested)
ct.ThrowIfCancellationRequested();
int startIndex = 0;
while (!ct.IsCancellationRequested)
{
if (ct.IsCancellationRequested)
ct.ThrowIfCancellationRequested();
if (startIndex == 0)
{
for (int i = 0; i < 15; i++)
{
Thread.Sleep(100);
if (ct.IsCancellationRequested)
ct.ThrowIfCancellationRequested();
}
}
else
Thread.Sleep(100);
if (ct.IsCancellationRequested)
ct.ThrowIfCancellationRequested();
string labelText = GetLabelTextNew(startIndex, fullText);
this.Dispatcher.Dispatch(() => this.Text = labelText);
startIndex = (startIndex + 1) % fullText.Length;
}
}
string GetLabelTextNew(int startIndex, string fullText)
{
int viewLength = fullText.Length;
string labelText;
if (startIndex < fullText.Length)
labelText = fullText.Substring(startIndex, Math.Min(viewLength, fullText.Length - startIndex));
else
{
int textStartIndex = startIndex - fullText.Length;
labelText = fullText.Substring(textStartIndex, Math.Min(viewLength, fullText.Length - textStartIndex));
}
if (labelText.Length < viewLength)
labelText += fullText.Substring(0, viewLength - labelText.Length);
return labelText;
}
public bool StopScroll()
{
if (taskScroll != null)
tokenSource2.Cancel();
while (taskScroll != null && !taskScroll.IsCanceled && !taskScroll.IsFaulted &&
!taskScroll.IsCompleted && taskScroll.Status == TaskStatus.Running)
{
//wait for task to cancel
}
return true;
}
}
2. Knowing When to Scroll: Handlers and Listeners
But how does it know when to scroll? We only want the animation to start if the text is actually cut off. This is where platform-specific logic and MAUI handlers come in.
For Android, we use a custom **ScrollingLabelInternalViewHandler. MAUI handlers are the mechanism we use to customize how abstract controls (like our `ScrollingLabelInternal`**) are rendered on each platform.
Inside our handler, we attach a **TextViewLayoutListener. This is an Android-specific listener that gets notified after the native `TextView` **has been measured and laid out on the screen.
// From ScrollingLabelInternalViewHandler.cs
protected override void ConnectHandler(TextView platformView)
{
base.ConnectHandler(platformView);
ControlIfScrollNeeded();
}
void ControlIfScrollNeeded()
{
if (VirtualView != null && PlatformView != null)
{
PlatformView.ViewTreeObserver.AddOnGlobalLayoutListener(
new TextViewLayoutListner(PlatformView, VirtualView));
}
}
The listener’s OnGlobalLayout method is our moment of truth. Here, we can inspect the native TextView and check if an ellipsis (...) is being shown.
// From TextViewLayoutListner.cs
public void OnGlobalLayout()
{
textView.ViewTreeObserver.RemoveOnGlobalLayoutListener(this);
if (textView.Layout.LineCount > 0)
_scrollingLabelInternal.StartScroll(textView.Layout.GetEllipsisCount(0) > 0);
else
_scrollingLabelInternal.StartScroll(false);
}
If GetEllipsisCount() is greater than zero, we call back into our cross-platform **ScrollingLabelInternal **control and tell it to StartScroll(). If not, it just stays put.
3. Getting the Fade Just Right: DisplayInfoService
To make the fade effect on the sides look good, we need to know how much space the ellipsis takes up. On Android, this can vary based on the font size and style.
I created a helper called **DisplayInfoService to solve this. It uses native Android APIs to measure a string of text before it's rendered on screen. It creates a temporary `TextView` **in memory, sets the text and font properties, measures it, and returns the width and height.
// From DisplayInfoService.cs
public static Size MeasureTextSize(string text, double fontSize, string fontName = null)
{
var textView = new TextView(MauiApplication.Context);
textView.Typeface = GetTypeface(fontName);
textView.SetText(text, TextView.BufferType.Normal);
textView.SetTextSize(ComplexUnitType.Px, (float)fontSize);
int widthMeasureSpec = global::Android.Views.View.MeasureSpec.MakeMeasureSpec(0, MeasureSpecMode.Unspecified);
int heightMeasureSpec = global::Android.Views.View.MeasureSpec.MakeMeasureSpec(0, MeasureSpecMode.Unspecified);
textView.Measure(widthMeasureSpec, heightMeasureSpec);
return new Size((double)textView.MeasuredWidth, (double)textView.MeasuredHeight);
}
Inside our **ScrollingLabelInternal**, we use this service to measure the width of " ..." to get the perfect fade effect width, making the UI look polished and professional.
4. Starting and Stopping Safely: The CancellationTokenSource
Running a continuous loop in a background **Task is powerful, but it can also be dangerous if not managed correctly. What happens if the text changes while it's already scrolling? We can't just start a new `Task`**—we'd have multiple loops running at once, fighting to update the same label!
This is where the **CancellationTokenSource comes in. It's the standard .NET way to safely signal to a `Task` **that it should stop what it's doing.
Before starting a new scroll animation, we always call StopScroll().
// From ScrollingLabelInternal.cs
public bool StopScroll()
{
if (taskScroll != null)
tokenSource2.Cancel(); // Signal cancellation
// Wait for the task to fully stop
while (taskScroll != null && !taskScroll.IsCanceled &&
!taskScroll.IsFaulted &&
!taskScroll.IsCompleted &&
taskScroll.Status == TaskStatus.Running)
{
// ... wait ...
}
return true; // Now it's safe to start a new task
}

That’s a Wrap!
And there you have it! By combining a cross-platform animation loop with platform-specific listeners and handlers, we can create a powerful, reusable, and great-looking **ScrollingLabel**. It’s a perfect example of how flexible the .NET MAUI architecture is.
Happy coding, and see you for the next MAUI UI July post! 🚀
메타데이터
- post_id
- c153c8ceb9dd
- slug
- calin-ciurariu-c153c8ceb9dd
- url
- https://medium.com/@clinciurariu/calin-ciurariu-c153c8ceb9dd
- canonical_url
- https://medium.com/@clinciurariu/calin-ciurariu-c153c8ceb9dd
- author_url
- https://medium.com/@clinciurariu
- status
- ok
- fetched_at
- 2026-08-18 16:26:54