home | How to display a very long text in Xamarin.Forms

4/20/2020 8:09:38 AM

#programming #xamarin #xamarin-forms #csharp #mobile

hero image

Maybe you had to display a huge text (+1500 words) in a mobile app and maybe not, but I had to and it was a struggle

In this blog, I'm sharing my experience and the solutions I tried and which worked best.

Before I start, all of it in one label inside a ScrollView is a No

WebView

probably the first thing to pop into one's mind, read the text, put it in a fairly simple HTML, set it as WebView's source, profit.

I tried this first, it worked until for some reason it had an unbearable performance on iOS. Less than 2 seconds to view on Android (Honor 6X) compared to +15 seconds on iOS (iPhone 8 Plus). Who would think! To be honest, I totally expected it to be the opposite when I first ran it. Custom renderer did not work, and neither did switching back to the deprecated UIWebView

CollectionView

After long hours of trying things with WebView, I was certain that it is NOT the way and I have to find an alternative. It is CollectionView (ListView is probably going to work if for some reason you cannot update to a version that has CollectionView). Let's get into it.

XAML

	<CollectionView
		ItemSizingStrategy="MeasureAllItems"
		ItemsSource="{Binding Strings}">
		<CollectionView.ItemTemplate>
			<DataTemplate>
				<Label FormattedText="{Binding .}" />
			</DataTemplate>
		</CollectionView.ItemTemplate>
	</CollectionView>

As seen above, the xaml is pretty straight forward

Below we can see the Task called Load that reads an embedded txt file then we take the result by chunks each one contains no more than 100 words (separated by space, this number and parameter can surely be changed to fit)

ViewModel

	async Task Load()
	{
		if (string.IsNullOrEmpty(Text))
		{
			var assembly = typeof(NewLawViewModel).GetTypeInfo().Assembly;
			using (Stream stream = assembly.GetManifestResourceStream($"App.Files.File.txt"))
			{
				using var reader = new StreamReader(stream);
				Text = await reader.ReadToEndAsync();
			}
		}

        
        var list = new List<FormattedString>();
        foreach (var c in text.Split(" ").ToList().Chunk(100))
        {
            var sb = new StringBuilder();
            foreach (var s in c)
            {
                sb.Append($"{s} ");
            }
            var fs = new FormattedString();
            fs.Spans.Add(new Span()
            {
                Text = sb.ToString(),
                BackgroundColor = Color.FromHex("fafaf9"),
                ForegroundColor = Color.FromHex("de000000")
            });
            list.Add(fs);
        }
				
        MainThread.BeginInvokeOnMainThread(() =>
        {
            Strings = new ObservableCollection<FormattedString>(list);
            IsBusy = false;
        });
	}

you probably are think of going off the page because formatted strings seem to be overkill, I agree, but I had to because I had to implement search and highlight the results. So let's see.

	void Search()
	{
			if (string.IsNullOrWhiteSpace(SearchText))
			{
					Task.Run(Load);
					RaisePropertyChanged(nameof(SearchCount));
					return;
			}

			var list = new List<FormattedString>();
			foreach (var c in Text.Split(" ").ToList().Chunk(100))
			{
					var sb = new StringBuilder();
					foreach (var s in c)
					{
							sb.Append($"{s} ");
					}
					var sbstr = sb.ToString();
					searchIndexes = sbstr.AllIndexesOf(SearchText).ToList();
					var fs = new FormattedString();
					if (searchIndexes.Count > 0)
					{
						// add the text from the beginning to the first index
						fs.Spans.Add(new Span()
						{
							Text = sbstr.Substring(0, searchIndexes[0]),
							BackgroundColor = Color.White,
							ForegroundColor = Color.Black
						});
						// let's loop
						for (int j = 0; j < searchIndexes.Count; j++)
						{
							// current index of found positions
							var index = searchIndexes[j];
							// if there is/are trailing and/or leading spaces
							bool trailingSpace = true, leadingSpace = true;
							// check for spaces
							if (index + SearchText.Length < sbstr.Length)
								trailingSpace = sbstr[index + SearchText.Length] == ' ';
							if (index - 1 > -1)
								leadingSpace = sbstr[index - 1] == ' ';
							fs.Spans.Add(new Span()
							{
								// this is the one we look for, let's distinguish
								Text = $"{(leadingSpace ? " " : "")}{sbstr.Substring(index, SearchText.Length)}{(trailingSpace ? " " : "")}",
								BackgroundColor = Color.Yellow,
								ForegroundColor = Color.Red
							});

							if (j + 1 < searchIndexes.Count)
							{
								try
								{
									var k = index + SearchText.Length;
									fs.Spans.Add(new Span()
									{
										Text = sbstr[k..searchIndexes[j + 1]],
										BackgroundColor = Color.FromHex("fafaf9"),
										ForegroundColor = Color.FromHex("de000000")
									});
								}
								catch
								{
									// probably won't catch
									continue;
								}
							}
						}
						var initI = searchIndexes.Last() + SearchText.Length;
						if (initI < sbstr.Length)
							fs.Spans.Add(new Span()
							{
								Text = sbstr.Substring(initI),
								BackgroundColor = Color.FromHex("fafaf9"),
								ForegroundColor = Color.FromHex("de000000")
							});
					}
					else
					{
						// what we want is not found
						fs.Spans.Add(new Span()
						{
							Text = sbstr,
							BackgroundColor = Color.FromHex("fafaf9"),
							ForegroundColor = Color.FromHex("de000000")
						});
					}
					list.Add(new LawTextItem { FormattedText = fs });
			}
			MainThread.BeginInvokeOnMainThread(() =>
			{
				// update the list
				Strings = new ObservableCollection<FormattedString>(list);
				RaisePropertyChanged(nameof(SearchCount));
				IsBusy = false;
			});
	}

And the performance now is amazing due which can be seen due to different factor each contributing on its own. CollectionView is significantly more performant than ListView, using yield return in the extensions, and the fact that we divided our huge text into manageable and cache-able pieces

Hope you have gained something reading this, cheers

P.S.: those are the two extensions used above

"chunk" extension

	public static IEnumerable<IEnumerable<T>> Chunk<T>(this IEnumerable<T> source, int chunksize)
    {
        while (source.Any())
        {
            yield return source.Take(chunksize);
            source = source.Skip(chunksize);
        }
    }

AllIndexesOf

	public static IEnumerable<int> AllIndexesOf(this string str, string value)
	{
		if (string.IsNullOrEmpty(value))
			throw new ArgumentException("the string to find may not be empty", "value");
		for (int index = 0; ; index += value.Length)
        {
			index = str.IndexOf(value, index);
			if (index == -1)
				break;
			yield return index;
         }
	}