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
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
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.
<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)
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
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);
}
}
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;
}
}