From 0bc41ade2cbef2868b34976fb10aa715604c0f57 Mon Sep 17 00:00:00 2001 From: Matvei Stefarov Date: Mon, 11 Sep 2023 08:37:03 -0700 Subject: [PATCH 1/6] popup: Implement text PopupElement for MAUI --- .../PopupViewer/TextPopupElementView.Maui.cs | 425 +++++++++++++++++- 1 file changed, 416 insertions(+), 9 deletions(-) diff --git a/src/Toolkit/Toolkit/UI/Controls/PopupViewer/TextPopupElementView.Maui.cs b/src/Toolkit/Toolkit/UI/Controls/PopupViewer/TextPopupElementView.Maui.cs index bccd295db..854b4368d 100644 --- a/src/Toolkit/Toolkit/UI/Controls/PopupViewer/TextPopupElementView.Maui.cs +++ b/src/Toolkit/Toolkit/UI/Controls/PopupViewer/TextPopupElementView.Maui.cs @@ -17,6 +17,11 @@ #if MAUI using Microsoft.Maui.Controls.Internals; using Esri.ArcGISRuntime.Mapping.Popups; +using Esri.ArcGISRuntime.Toolkit.Internal; +using Microsoft.Maui.ApplicationModel; +using Esri.ArcGISRuntime.UI; +using Grid = Microsoft.Maui.Controls.Grid; +using RuntimeImageExtensions = Esri.ArcGISRuntime.Maui.RuntimeImageExtensions; namespace Esri.ArcGISRuntime.Toolkit.Maui.Primitives { @@ -27,9 +32,10 @@ namespace Esri.ArcGISRuntime.Toolkit.Maui.Primitives public partial class TextPopupElementView : TemplatedView { private static readonly ControlTemplate DefaultControlTemplate; + private static Thickness ParagraphMargin = new(0, 0, 0, 16); /// - /// Template name of the text area. + /// Template name of the text area. /// public const string TextAreaName = "TextArea"; @@ -40,7 +46,7 @@ static TextPopupElementView() private static object BuildDefaultTemplate() { - Label textArea = new Label(); + var textArea = new StackLayout(); INameScope nameScope = new NameScope(); NameScope.SetNameScope(textArea, nameScope); nameScope.RegisterName(TextAreaName, textArea); @@ -49,17 +55,418 @@ private static object BuildDefaultTemplate() private void OnElementPropertyChanged() { - var label = GetTemplateChild(TextAreaName) as Label; - if (label is null) return; + var container = GetTemplateChild(TextAreaName) as StackLayout; var text = Element?.Text; - + if (container is null || string.IsNullOrEmpty(text)) + return; + + try + { + container.Children.Clear(); + var htmlRoot = HtmlUtility.BuildDocumentTree(text); + var blocks = VisitAndAddBlocks(htmlRoot); + foreach (var block in blocks) + container.Children.Add(block); + } + catch + { + container.Children.Clear(); + // Fallback if something went wrong with the parsing: + // Just display the text without any markup; + var label = new Label(); #if !WINDOWS - label.TextType = TextType.Html; + label.TextType = TextType.Html; #else - if (text != null) - text = Toolkit.Internal.StringExtensions.ToPlainText(text); + if (text != null) + text = Toolkit.Internal.StringExtensions.ToPlainText(text); #endif - label.Text = text; + label.Text = text; + container.Children.Add(label); + } + } + + private static IEnumerable VisitAndAddBlocks(MarkupNode parent) + { + List? inlineNodes = null; + foreach (var node in parent.Children) + { + node.InheritAttributes(parent); + if (MapsToBlock(node) || HasAnyBlocks(node)) + { + if (inlineNodes != null) + { + var label = VisitAndAddInlines(inlineNodes); + ApplyStyle(label, parent); + inlineNodes = null; + yield return label; + } + yield return VisitBlock(node); + } + else + { + inlineNodes ??= new List(); + inlineNodes.Add(node); + } + } + if (inlineNodes != null) + { + var label = VisitAndAddInlines(inlineNodes); + ApplyStyle(label, parent); + yield return label; + } + } + + private static View VisitBlock(MarkupNode node) + { + switch (node.Type) + { + case MarkupType.List: + // Lists (li and ol) are laid out in a grid, with a narrow column of markers and a wide one for the content. + // +-----+----------------------+ + // | 1. | First item content | + // +-----+----------------------+ + // | 2. | Second item content | + // +-----+----------------------+ + // | ... | + // +-----+----------------------+ + // | 3. | Last item content | + // +-----+----------------------+ + bool isOredered = node.Token?.Name == "ol"; + var listGrid = new Grid { Margin = new Thickness(0, 0, 0, 16) }; + listGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // bullets + listGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Star }); // contents + + var childItems = node.Children.Where(n => n.Type == MarkupType.ListItem).ToList(); // ignore a misplaced non-list-item node + + for (int row = 0; row < childItems.Count; row++) + { + listGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + var markerText = isOredered ? $"{row + 1}." : "\u2022"; + listGrid.Add(new Label { Text = markerText, HorizontalTextAlignment = TextAlignment.End, Margin = new Thickness(5, 0) }, 0, row); + var item = VisitBlock(childItems[row]); + listGrid.Add(item, 1, row); + } + return listGrid; + + case MarkupType.Block: + case MarkupType.ListItem: + case MarkupType.TableCell: + bool isPara = node.Token?.Name == "p"; + View view; + if (HasAnyBlocks(node)) + { + var container = new StackLayout(); + if (isPara) + container.Margin = ParagraphMargin; + if (node.BackColor.HasValue) + container.BackgroundColor = ConvertColor(node.BackColor.Value); + + var blocks = VisitAndAddBlocks(node); + foreach (var block in blocks) + { + container.Children.Add(block); + } + view = container; + } + else + { + var label = VisitAndAddInlines(node.Children); + if (isPara) + label.Margin = ParagraphMargin; + ApplyStyle(label, node); + view = label; + } + if (node.Type == MarkupType.TableCell) + return VerticallyAlignTableCell(node, view); + return view; + + case MarkupType.Divider: + return new BoxView { HeightRequest = 1, Color = Colors.Gray }; // TODO: Do we need to set a color? + + case MarkupType.Table: + return ConvertTableToGrid(node); + + case MarkupType.Image: + var imageElement = new Image(); + if (Uri.TryCreate(node.Content, UriKind.Absolute, out var imgUri)) + { + imageElement.Loaded += OnImageElementLoaded; + + async void OnImageElementLoaded(object? sender, EventArgs e) + { + imageElement.Loaded -= OnImageElementLoaded; + var taggedUri = imgUri; + var ri = new RuntimeImage(taggedUri); // Use Runtime's caching and authentication + try + { + imageElement.Source = await RuntimeImageExtensions.ToImageSourceAsync(ri); + } + catch + { + // Don't let one bad image take down the whole app. Better to ignore a failed image load. + } + } + } + return imageElement; + + default: + return new Border(); // placeholder for unsupported things + } + + static View VerticallyAlignTableCell(MarkupNode node, View cellContent) + { + cellContent.VerticalOptions = LayoutOptions.Center; + // In HTML, table cells are vertically centered by default. + if (node.BackColor.HasValue) + { + var grid = new Grid { cellContent }; + grid.BackgroundColor = ConvertColor(node.BackColor.Value); + return grid; + } + else + { + return cellContent; + } + } + } + + private static Label VisitAndAddInlines(IEnumerable nodes) + { + // Flattens given tree of inline nodes into a single FormattedText + var str = new FormattedString(); + foreach (var node in nodes) + { + foreach (var span in VisitInline(node)) + { + str.Spans.Add(span); + } + } + return new Label { FormattedText = str, LineBreakMode = LineBreakMode.WordWrap }; + } + + private static IEnumerable VisitInline(MarkupNode node) + { + switch (node.Type) + { + case MarkupType.Link: + if (Uri.TryCreate(node.Content, UriKind.Absolute, out var linkUri)) + { + // The gesture recognizer will be shared by all the individual spans + var tapRecognizer = new TapGestureRecognizer(); + tapRecognizer.Tapped += (s, e) => + { + try + { + Browser.OpenAsync(node.Content, BrowserLaunchMode.SystemPreferred); + } + catch { } + }; + foreach (var subNode in node.Children) + { + subNode.InheritAttributes(node); + foreach (var subSpan in VisitInline(subNode)) + { + ApplyStyle(subSpan, node); + if (node.IsUnderline != false) // Add underline to links by default (unless specifically disabled) + subSpan.TextDecorations = TextDecorations.Underline; + + if (subSpan.GestureRecognizers.Count == 0) + subSpan.GestureRecognizers.Add(tapRecognizer); + yield return subSpan; + } + } + } + else + { + // Fallback: treat it as a regular span + goto case MarkupType.Span; + } + break; + + case MarkupType.Span: + case MarkupType.Sub: // Font variants are not supported on MAUI + case MarkupType.Sup: // Font variants are not supported on MAUI + foreach (var subNode in node.Children) + { + subNode.InheritAttributes(node); + foreach (var subSpan in VisitInline(subNode)) + { + yield return subSpan; + } + } + break; + + case MarkupType.Break: + yield return new Span { Text = Environment.NewLine }; + break; + + case MarkupType.Text: + var textSpan = new Span { Text = node.Content }; + ApplyStyle(textSpan, node); + yield return textSpan; + break; + + default: + break; + } + } + + private static Grid ConvertTableToGrid(MarkupNode table) + { + // Determines the dimensions of a grid necessary to hold a given table. + // Utilizes a dynamically-sized 2D bitmap (`grid`) to mark occupied cells while iterating over the table. + // Expands the grid as necessary based on cell spans and avoids collisions by checking the bitmap. + List> gridMap = new List>(); + + int maxRowUsed = -1; + int maxColUsed = -1; + + var gridView = new Grid(); + + int curRow = 0; + foreach (MarkupNode tr in table.Children) + { + tr.InheritAttributes(table); + int curCol = 0; + foreach (MarkupNode td in tr.Children) + { + // Find the next available cell in this row + EnsureColumnExists(curRow, curCol); + while (gridMap[curRow][curCol]) + { + curCol++; + EnsureColumnExists(curRow, curCol); + } + + int rowSpan = 1; + int colSpan = 1; + + // Create a View for the current table-cell, and add it to the grid. + td.InheritAttributes(tr); + var cellView = VisitBlock(td); + var attr = HtmlUtility.ParseAttributes(td.Token?.Attributes); + if (attr.TryGetValue("colspan", out var colSpanStr) && ushort.TryParse(colSpanStr, out var colSpanFromAttr)) + { + colSpan = colSpanFromAttr; + Grid.SetColumnSpan(cellView, colSpan); + } + if (attr.TryGetValue("rowspan", out var rowSpanStr) && ushort.TryParse(rowSpanStr, out var rowSpanFromAttr)) + { + rowSpan = rowSpanFromAttr; + Grid.SetRowSpan(cellView, colSpan); + } + gridView.Add(cellView, curCol, curRow); + + // Mark grid-cells occupied by the current table-cell + for (int i = 0; i < rowSpan; i++) + { + for (int j = 0; j < colSpan; j++) + { + EnsureColumnExists(curRow + i, curCol + j); + + gridMap[curRow + i][curCol + j] = true; + + maxRowUsed = Math.Max(maxRowUsed, curRow + i); + maxColUsed = Math.Max(maxColUsed, curCol + j); + } + } + curCol += colSpan; + } + curRow++; + } + + // Now we know exactly how many rows and columns were necessary to hold the table. Allocate them! + for (int i = 0; i <= maxRowUsed; i++) + gridView.RowDefinitions.Add(new RowDefinition()); + for (int i = 0; i <= maxColUsed; i++) + gridView.ColumnDefinitions.Add(new ColumnDefinition()); + + return gridView; + + // Expand the gridMap as needed to make sure that given row/column exists + void EnsureColumnExists(int row, int col) + { + while (gridMap.Count <= row) + gridMap.Add(new List()); + while (gridMap[row].Count <= col) + gridMap[row].Add(false); + } + } + + private static void ApplyStyle(Span el, MarkupNode node) + { + if (node.IsBold == true) + el.FontAttributes |= FontAttributes.Bold; + else if (node.IsBold == false) + el.FontAttributes &= ~FontAttributes.Bold; + + if (node.IsItalic == true) + el.FontAttributes |= FontAttributes.Italic; + else if (node.IsItalic == false) + el.FontAttributes &= ~FontAttributes.Italic; + + if (node.IsUnderline == true) + el.TextDecorations |= TextDecorations.Underline; + else if (node.IsUnderline == false) + el.TextDecorations &= ~TextDecorations.Underline; + + if (node.FontColor.HasValue) + el.TextColor = ConvertColor(node.FontColor.Value); + if (node.BackColor.HasValue) + el.BackgroundColor = ConvertColor(node.BackColor.Value); + if (node.FontSize.HasValue) + el.FontSize = 16d * node.FontSize.Value; // based on AGOL's default font size + } + + private static void ApplyStyle(Label el, MarkupNode node) + { + if (node.IsBold == true) + el.FontAttributes |= FontAttributes.Bold; + else if (node.IsBold == false) + el.FontAttributes &= ~FontAttributes.Bold; + + if (node.IsItalic == true) + el.FontAttributes |= FontAttributes.Italic; + else if (node.IsItalic == false) + el.FontAttributes &= ~FontAttributes.Italic; + + if (node.IsUnderline == true) + el.TextDecorations |= TextDecorations.Underline; + else if (node.IsUnderline == false) + el.TextDecorations &= ~TextDecorations.Underline; + + if (node.FontColor.HasValue) + el.TextColor = ConvertColor(node.FontColor.Value); + if (node.BackColor.HasValue) + el.BackgroundColor = ConvertColor(node.BackColor.Value); + if (node.FontSize.HasValue) + el.FontSize = 16d * node.FontSize.Value; // based on AGOL's default font size + + if (node.Alignment.HasValue) + el.HorizontalTextAlignment = ConvertAlignment(node.Alignment); + } + + private static TextAlignment ConvertAlignment(HtmlAlignment? alignment) => alignment switch + { + HtmlAlignment.Left => TextAlignment.Start, + HtmlAlignment.Center => TextAlignment.Center, + HtmlAlignment.Right => TextAlignment.End, + _ => TextAlignment.Start, + }; + + private static Color ConvertColor(System.Drawing.Color color) + { + return Color.FromRgba(color.R, color.G, color.B, color.A); + } + + private static bool HasAnyBlocks(MarkupNode node) + { + return node.Children.Any(c => MapsToBlock(c) || HasAnyBlocks(c)); + } + + private static bool MapsToBlock(MarkupNode node) + { + return node.Type is MarkupType.List or MarkupType.Table or MarkupType.Block or MarkupType.Divider or MarkupType.Image; } } } From 0dacd1e37b70853565af7c11cfb970b2e38b4289 Mon Sep 17 00:00:00 2001 From: Matvei Stefarov Date: Mon, 11 Sep 2023 09:04:52 -0700 Subject: [PATCH 2/6] Work around MAUI layout bug in the sample "Margin" was causing content to shift and clip --- .../Toolkit.SampleApp.Maui/Samples/PopupViewerSample.xaml | 2 +- src/Toolkit/Toolkit/UI/Controls/PopupViewer/PopupViewer.Maui.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Samples/Toolkit.SampleApp.Maui/Samples/PopupViewerSample.xaml b/src/Samples/Toolkit.SampleApp.Maui/Samples/PopupViewerSample.xaml index cd86fd311..f82700c30 100644 --- a/src/Samples/Toolkit.SampleApp.Maui/Samples/PopupViewerSample.xaml +++ b/src/Samples/Toolkit.SampleApp.Maui/Samples/PopupViewerSample.xaml @@ -12,7 +12,7 @@ - +