[MAUI]寫一個跨平臺富文字編輯器

2023-06-12 06:00:17

@


富文字編輯器是一種所見即所得(what you see is what you get 簡稱 WYSIWYG)文字編輯器,使用者在編輯器中輸入內容和所做的樣式修改,都會直接反映在編輯器中。

在Web端常見的有QuillTinyMCE這些開源免費的富文字編輯器,而目前.NET MAUI方面沒有類似的富文字編輯器可以免費使用。

使用.NET MAUI實現一個富文字編輯器並不難,今天就來寫一個

使用.NET MAU實現跨平臺支援,本專案可執行於Android、iOS平臺。由於篇幅本文只展示Android平臺的程式碼。

原理

.NET MAUI提供了編輯器控制元件,允許輸入和編輯多行文字,雖然提供了字號,字型,顏色等控制元件屬性,但我們無法為每個字元設定樣式。我們將通過原生控制元件提供的範圍選擇器實現這一功能。

.NET MAUI提供了Handler的跨平臺特性,我們將利用Handler實現所見即所得內容編輯器元件。這篇博文介紹瞭如何用Handler實現自定義跨平臺控制元件,請閱讀[MAUI程式設計] 用Handler實現自定義跨平臺控制元件

在各平臺中,我們將使用原生控制元件實現所見即所得的內容編輯器

  • Android使用SpannableString設定文字的複合樣式,可以檢視https://www.cnblogs.com/jisheng/archive/2013/01/10/2854088.html

  • iOS使用NSAttributeString設定文字的複合樣式,可以參考https://blog.csdn.net/weixin_44544690/article/details/124154949

建立編輯器

新建.NET MAUI專案,命名RichTextEditor

在Controls目錄中建立WysiwygContentEditor,繼承自Editor,用於實現所見即所得的內容編輯器

建構函式中註冊HandlerChanged和HandlerChanging事件


public class WysiwygContentEditor : Editor
{
    public WysiwygContentEditor()
    {
        HandlerChanged+=WysiwygContentEditor_HandlerChanged;
        HandlerChanging+=WysiwygContentEditor_HandlerChanging;
    }

}

在HandlerChanged事件中,獲取Handler物件,通過它存取虛擬檢視和本機檢視。

private void WysiwygContentEditor_HandlerChanged(object sender, EventArgs e)
{
    var handler = Handler;
    if (handler != null)
    {
    }
}

android端原生控制元件為AppCompatEditText,iOS端原生控制元件為UITextView

//Android
var platformView = handler.PlatformView as AppCompatEditText;
//iOS
var platformView = handler.PlatformView as UITextView;

不同平臺的程式碼,通過.Net6的條件編譯實現,有關條件編譯的詳細資訊,請參考官方檔案。這次實現的是Android和iOS平臺,所以在程式碼中條件編譯語句如下

#if ANDROID

//android codes
...


#endif

#if IOS

//iOS codes
...

#endif


定義

定義StyleType列舉,用於控制元件可以處理的文字樣式更改請求型別。

  • underline:字型下劃線
  • italic:字型斜體
  • bold:字型加粗
  • backgoundColor:字型背景色
  • foregroundColor:字型前景色
  • size:字型大小
public enum StyleType
{
    underline, italic, bold, backgoundColor, foregroundColor, size
}


以及StyleArgs類,用於傳遞樣式變更請求的引數

public class StyleArgs : EventArgs
{
    public StyleType Style;

    public string Params;
    public StyleArgs(StyleType style, string @params = null)
    {
        Style = style;
        Params=@params;
    }
}

定義SelectionArgs類,用於傳遞選擇範圍變更請求的引數

public class SelectionArgs : EventArgs
{
    public int Start;
    public int End;
    public SelectionArgs(int start, int end)
    {
        Start = start;
        End = end;
    }
}

定義事件用於各平臺本機程式碼的呼叫

public event EventHandler GetHtmlRequest;
public event EventHandler<string> SetHtmlRequest;
public event EventHandler<StyleArgs> StyleChangeRequested;
public event EventHandler<SelectionArgs> SelectionChangeHandler;

建立StyleChangeRequested的訂閱事件以響應樣式變更請求,對應不同的樣式型別,呼叫不同的方法實現樣式變更。


StyleChangeRequested =new EventHandler<StyleArgs>(
(sender, e) =>

{
    var EditableText = platformView.EditableText;

    switch (e.Style)
    {
        case StyleType.underline:
            UpdateUnderlineSpans(EditableText);
            break;
        case StyleType.italic:
            UpdateStyleSpans(TypefaceStyle.Italic, EditableText);
            break;
        case StyleType.bold:
            UpdateStyleSpans(TypefaceStyle.Bold, EditableText);
            break;
        case StyleType.backgoundColor:
            UpdateBackgroundColorSpans(EditableText, Microsoft.Maui.Graphics.Color.FromArgb(e.Params));
            break;
        case StyleType.foregroundColor:
            UpdateForegroundColorSpans(EditableText, Microsoft.Maui.Graphics.Color.FromArgb(e.Params));
            break;
        case StyleType.size:
            UpdateAbsoluteSizeSpanSpans(EditableText, int.Parse(e.Params));
            break;
        default:
            break;
    }


});


實現複合樣式

選擇範圍

android端使用SelectionStart和SelectionEnd獲取選擇範圍,iOS端使用SelectedRange獲取選擇範圍

//Android

int getSelectionStart() => platformView.SelectionStart;
int getSelectionEnd() => platformView.SelectionEnd;

//iOS
NSRange getSelectionRange() => platformView.SelectedRange;


字號

MAUI控制元件中字號使用FontSize屬性單位為邏輯畫素,與DPI設定相關聯。
在android本機平臺中,字號通過為EditableText物件設定AbsoluteSizeSpan實現,程式碼如下


void UpdateAbsoluteSizeSpanSpans(IEditable EditableText, int size)
{

    var spanType = SpanTypes.InclusiveInclusive;

    EditableText.SetSpan(new AbsoluteSizeSpan(size, true), getSelectionStart(), getSelectionEnd(), spanType);
    SetEditableText(EditableText, platformView);
}

字型顏色與背景色

Android平臺中,字型顏色與背景色通過為EditableText物件設定ForegroundColorSpan和BackgroundColorSpan實現

void UpdateForegroundColorSpans(IEditable EditableText, Microsoft.Maui.Graphics.Color color)
{
    var spanType = SpanTypes.InclusiveInclusive;
    EditableText.SetSpan(new ForegroundColorSpan(color.ToAndroid()), getSelectionStart(), getSelectionEnd(), spanType);
    SetEditableText(EditableText, platformView);
}

void UpdateBackgroundColorSpans(IEditable EditableText, Microsoft.Maui.Graphics.Color color)
{
    var spanType = SpanTypes.InclusiveInclusive;
    EditableText.SetSpan(new BackgroundColorSpan(color.ToAndroid()), getSelectionStart(), getSelectionEnd(), spanType);
    SetEditableText(EditableText, platformView);
}

字型下劃線

將選擇文字選擇範圍內若包含下劃線,則移除下劃線,否則新增下劃線

Android平臺中通過為EditableText物件設定UnderlineSpan實現為文字新增下劃線,通過RemoveSpan方法可以移除下劃線,

但選擇範圍可能已包含下劃線片段的一部分,因此移除此下劃線片段後,需要重新新增下劃線片段,以實現部分移除的效果


void UpdateUnderlineSpans(IEditable EditableText)
{

    var underlineSpans = EditableText.GetSpans(getSelectionStart(), getSelectionEnd(), Java.Lang.Class.FromType(typeof(UnderlineSpan)));

    bool hasFlag = false;
    var spanType = SpanTypes.InclusiveInclusive;

    foreach (var span in underlineSpans)
    {
        hasFlag = true;

        var spanStart = EditableText.GetSpanStart(span);
        var spanEnd = EditableText.GetSpanEnd(span);
        var newStart = spanStart;
        var newEnd = spanEnd;
        var startsBefore = false;
        var endsAfter = false;

        if (spanStart < getSelectionStart())
        {
            newStart = getSelectionStart();
            startsBefore = true;
        }
        if (spanEnd > getSelectionEnd())
        {
            newEnd = getSelectionEnd();
            endsAfter = true;
        }

        EditableText.RemoveSpan(span);

        if (startsBefore)
        {
            EditableText.SetSpan(new UnderlineSpan(), spanStart, newStart, SpanTypes.ExclusiveExclusive);
        }
        if (endsAfter)
        {
            EditableText.SetSpan(new UnderlineSpan(), newEnd, spanEnd, SpanTypes.ExclusiveExclusive);
        }
    }

    if (!hasFlag)
    {
        EditableText.SetSpan(new UnderlineSpan(), getSelectionStart(), getSelectionEnd(), spanType);
    }
    SetEditableText(EditableText, platformView);
}


字型加粗與斜體

Android平臺中,字型粗細與斜體通過為EditableText物件設定StyleSpan實現,與設定字型下劃線一樣,需要處理選擇範圍內已包含StyleSpan的情況

TypefaceStyle提供了Normal、Bold、Italic、BoldItalic四種字型樣式,粗體+斜體樣式是通過組合實現的,因此需要處理樣式疊加問題


void UpdateStyleSpans(TypefaceStyle flagStyle, IEditable EditableText)
{
    var styleSpans = EditableText.GetSpans(getSelectionStart(), getSelectionEnd(), Java.Lang.Class.FromType(typeof(StyleSpan)));
    bool hasFlag = false;
    var spanType = SpanTypes.InclusiveInclusive;

    foreach (StyleSpan span in styleSpans)
    {
        var spanStart = EditableText.GetSpanStart(span);
        var spanEnd = EditableText.GetSpanEnd(span);
        var newStart = spanStart;
        var newEnd = spanEnd;
        var startsBefore = false;
        var endsAfter = false;

        if (spanStart < getSelectionStart())
        {
            newStart = getSelectionStart();
            startsBefore = true;
        }
        if (spanEnd > getSelectionEnd())
        {
            newEnd = getSelectionEnd();
            endsAfter = true;
        }

        if (span.Style == flagStyle)
        {
            hasFlag = true;
            EditableText.RemoveSpan(span);
            EditableText.SetSpan(new StyleSpan(TypefaceStyle.Normal), newStart, newEnd, spanType);
        }
        else if (span.Style == TypefaceStyle.BoldItalic)
        {
            hasFlag = true;
            EditableText.RemoveSpan(span);
            var flagLeft = TypefaceStyle.Bold;
            if (flagStyle == TypefaceStyle.Bold)
            {
                flagLeft = TypefaceStyle.Italic;
            }
            EditableText.SetSpan(new StyleSpan(flagLeft), newStart, newEnd, spanType);
        }

        if (startsBefore)
        {
            EditableText.SetSpan(new StyleSpan(span.Style), spanStart, newStart, SpanTypes.ExclusiveExclusive);
        }
        if (endsAfter)
        {
            EditableText.SetSpan(new StyleSpan(span.Style), newEnd, spanEnd, SpanTypes.ExclusiveExclusive);
        }

    }
    if (!hasFlag)
    {
        EditableText.SetSpan(new StyleSpan(flagStyle), getSelectionStart(), getSelectionEnd(), spanType);
    }

    SetEditableText(EditableText, platformView);
}

序列化和反序列化

所見即所得的內容需要被序列化和反序列化以便儲存或傳輸,我們仍然使用HTML作為中間語言,好在Android和iOS平臺都有HTML互轉的對應實現。

  • Android平臺中,Android.Text.Html提供了FromHtml()和Html.ToHtml(),
  • iOS中的NSAttributedStringDocumentAttributes提供了DocumentType屬性,可以設定為NSHTMLTextDocumentType,使用它初始化AttributedString或呼叫AttributedString.GetDataFromRange()方法實現HTML和NSAttributedString的互轉。

跨平臺實現

在Platform/Android目錄下建立HtmlParser.Android作為Android平臺序列化和反序列化的實現。

public static class HtmlParser_Android
{
    public static ISpanned HtmlToSpanned(string htmlString)
    {
        ISpanned spanned = Html.FromHtml(htmlString, FromHtmlOptions.ModeCompact);
        return spanned;
    }

    public static string SpannedToHtml(ISpanned spanned)
    {
        string htmlString = Html.ToHtml(spanned, ToHtmlOptions.ParagraphLinesIndividual);
        return htmlString;
    }
}

在Platform/iOS目錄下建立HtmlParser.iOS作為iOS平臺序列化和反序列化的實現。

public static class HtmlParser_iOS
{
    static nfloat defaultSize = UIFont.SystemFontSize;
    static UIFont defaultFont;

    public static NSAttributedString HtmlToAttributedString(string htmlString)
    {
        var nsString = new NSString(htmlString);
        var data = nsString.Encode(NSStringEncoding.UTF8);
        var dictionary = new NSAttributedStringDocumentAttributes();
        dictionary.DocumentType = NSDocumentType.HTML;
        NSError error = new NSError();
        var attrString = new NSAttributedString(data, dictionary, ref error);
        var mutString = ResetFontSize(new NSMutableAttributedString(attrString));

        return mutString;
    }

    static NSAttributedString ResetFontSize(NSMutableAttributedString attrString)
    {
        defaultFont = UIFont.SystemFontOfSize(defaultSize);

        attrString.EnumerateAttribute(UIStringAttributeKey.Font, new NSRange(0, attrString.Length), NSAttributedStringEnumeration.None, (NSObject value, NSRange range, ref bool stop) =>
        {
            if (value != null)
            {
                var oldFont = (UIFont)value;
                var oldDescriptor = oldFont.FontDescriptor;

                var newDescriptor = defaultFont.FontDescriptor;

                bool hasBoldFlag = false;
                bool hasItalicFlag = false;

                if (oldDescriptor.SymbolicTraits.HasFlag(UIFontDescriptorSymbolicTraits.Bold))
                {
                    hasBoldFlag = true;
                }
                if (oldDescriptor.SymbolicTraits.HasFlag(UIFontDescriptorSymbolicTraits.Italic))
                {
                    hasItalicFlag = true;
                }

                if (hasBoldFlag && hasItalicFlag)
                {
                    uint traitsInt = (uint)UIFontDescriptorSymbolicTraits.Bold + (uint)UIFontDescriptorSymbolicTraits.Italic;
                    newDescriptor = newDescriptor.CreateWithTraits((UIFontDescriptorSymbolicTraits)traitsInt);
                }
                else if (hasBoldFlag)
                {
                    newDescriptor = newDescriptor.CreateWithTraits(UIFontDescriptorSymbolicTraits.Bold);
                }
                else if (hasItalicFlag)
                {
                    newDescriptor = newDescriptor.CreateWithTraits(UIFontDescriptorSymbolicTraits.Italic);
                }

                var newFont = UIFont.FromDescriptor(newDescriptor, defaultSize);

                attrString.RemoveAttribute(UIStringAttributeKey.Font, range);
                attrString.AddAttribute(UIStringAttributeKey.Font, newFont, range);
            }

        });

        return attrString;
    }


    public static string AttributedStringToHtml(NSAttributedString attributedString)
    {
        var range = new NSRange(0, attributedString.Length);
        var dictionary = new NSAttributedStringDocumentAttributes();
        dictionary.DocumentType = NSDocumentType.HTML;
        NSError error = new NSError();
        var data = attributedString.GetDataFromRange(range, dictionary, ref error);
        var htmlString = new NSString(data, NSStringEncoding.UTF8);
        return htmlString;
    }
}

整合至編輯器

在所見即所得編輯器中設定兩個方法,一個用於獲取編輯器中的內容,一個用於設定編輯器中的內容。


public void SetHtmlText(string htmlString)
{
    HtmlString = htmlString;
    SetHtmlRequest(this, htmlString);
}



public string GetHtmlText()
{

    GetHtmlRequest(this, new EventArgs());
    return HtmlString;
}

在HandlerChanged事件方法中的各平臺程式碼段中新增如下程式碼:

GetHtmlRequest = new EventHandler(
    (sender, e) =>
        {
            var editor = (WysiwygContentEditor)sender;
            HtmlString=HtmlParser_Android.SpannedToHtml(platformView.EditableText);
        }
    );
SetHtmlRequest =new EventHandler<string>(
    (sender, htmlString) =>
        {
            platformView.TextFormatted = HtmlParser_Android.HtmlToSpanned(htmlString);
        }
    );

在富文字編輯器中的內容,最終會生成一個帶有內聯樣式的HTML字串,如下所示:

建立控制元件

控制元件由所見即所得編輯器和工具列組成,所見即所得編輯器用於顯示和編輯內容,工具列用於設定字號、顏色、加粗、斜體、下劃線

建立RichTextEditor的帶有Xaml的ContentView。將所見即所得編輯器放置中央,工具列放置在底部。

<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:controls="clr-namespace:RichTextEditor.Controls;assembly=RichTextEditor"
             x:Class="RichTextEditor.Controls.RichTextEditor">
    <Border>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="1*"></RowDefinition>
                <RowDefinition Height="Auto"></RowDefinition>
                <RowDefinition Height="Auto"></RowDefinition>
            </Grid.RowDefinitions>
                <controls:WysiwygContentEditor MinimumHeightRequest="150"
                        AutoSize="TextChanges"
                        BackgroundColor="{StaticResource PhoneContrastBackgroundBrush}"
                        IsSpellCheckEnabled="false"
                        x:Name="MainEditor"></controls:WysiwygContentEditor>
            </Grid>
    </Border>
</ContentView>

工具列內的按鈕橫向排列

<HorizontalStackLayout Grid.Row="3"
                        Spacing="5"
                        Margin="0,10">
    <Button Text="{Binding Source={x:Reference TextSizeCollectionView}, Path=SelectedItem.Name, FallbackValue=Auto}"
            Style="{StaticResource RichTextButtonStyle}"
            Clicked="TextSizeButton_Clicked"
            x:Name="TextSizeButton"></Button>
    <Button Text="Color"
            TextColor="{Binding Source={x:Reference ColorCollectionView}, Path=SelectedItem}"
            Style="{StaticResource RichTextButtonStyle}"
            Clicked="TextColorButton_Clicked"
            x:Name="TextColorButton"></Button>
    <Button Text="B"
            Style="{StaticResource RichTextButtonStyle}"
            FontAttributes="Bold"
            x:Name="BoldButton"
            Clicked="BoldButton_Clicked"></Button>
    <Button Text="I"
            Style="{StaticResource RichTextButtonStyle}"
            FontAttributes="Italic"
            x:Name="ItalicButton"
            Clicked="ItalicButton_Clicked"></Button>
    <Button Text="U"
            Style="{StaticResource RichTextButtonStyle}"
            FontAttributes="None"
            x:Name="UnderLineButton"
            Clicked="UnderLineButton_Clicked"></Button>
</HorizontalStackLayout>

設定兩個選擇器:TextSizeCollectionView為字型大小選擇器,ColorCollectionView為字型顏色選擇器。

當點選字型大小選擇器時,彈出字型大小選擇器,當點選字型顏色選擇器時,彈出字型顏色選擇器。

<VerticalStackLayout x:Name="OptionsLayout"
                        Grid.Row="2"
                        Spacing="5">
    <CollectionView x:Name="TextSizeCollectionView"
                    Background="Transparent"
                    SelectionChanged="TextSizeCollectionView_SelectionChanged"
                    SelectionMode="Single"
                    HeightRequest="45">
        <CollectionView.ItemsLayout>
            <LinearItemsLayout Orientation="Horizontal"
                                ItemSpacing="5"></LinearItemsLayout>
        </CollectionView.ItemsLayout>
        <CollectionView.ItemTemplate>
            <DataTemplate>

                <Border x:Name="TargetElement"
                        Style="{StaticResource SelectableLayoutStyle}"
                        Background="{StaticResource PhoneContrastBackgroundBrush}"
                        Padding="5,0">
                    <Label Text="{Binding Name}"
                            TextColor="{StaticResource PhoneForegroundBrush}"
                            VerticalOptions="Center"
                            FontSize="{Binding Value}"></Label>
                </Border>



            </DataTemplate>
        </CollectionView.ItemTemplate>
    </CollectionView>

    <CollectionView x:Name="ColorCollectionView"
                    SelectionChanged="ColorCollectionView_SelectionChanged"
                    SelectionMode="Single"
                    HeightRequest="45">
        <CollectionView.ItemsLayout>
            <LinearItemsLayout Orientation="Horizontal"
                                ItemSpacing="5"></LinearItemsLayout>
        </CollectionView.ItemsLayout>
        <CollectionView.ItemTemplate>
            <DataTemplate>

                <Border x:Name="TargetElement"
                        Style="{StaticResource SelectableLayoutStyle}"
                        BackgroundColor="{Binding}"
                        WidthRequest="40"
                        HeightRequest="40"
                        StrokeShape="RoundRectangle 40">

                </Border>

            </DataTemplate>
        </CollectionView.ItemTemplate>
    </CollectionView>
</VerticalStackLayout>

後端程式碼,繫結一些預設值


public static List<Color> DefaultTextColorList = new List<Color>() {
    Color.FromArgb("#000000"),
    Color.FromArgb("#F9371C"),
    Color.FromArgb("#F97C1C"),
    Color.FromArgb("#F9C81C"),
    Color.FromArgb("#41D0B6"),
    Color.FromArgb("#2CADF6"),
    Color.FromArgb("#6562FC")
};

public static List<TextSize> DefaultTextSizeList = new List<TextSize>() {
    new TextSize(){Name="Large", Value=22},
    new TextSize(){Name="Middle", Value=18},
    new TextSize(){Name="Small", Value=12},
};

效果如下:

使用控制元件

在MainPage中使用RichTextEditor,程式碼如下


<controls:RichTextEditor  
            x:Name="MainRichTextEditor"
            Text="{Binding Content}"
            Placeholder="{Binding PlaceHolder}"></controls:RichTextEditor>

MainRichTextEditor.GetHtmlText()測試獲取富文字編輯器Html序列化功能。

private async void Button_Clicked(object sender, EventArgs e)
{
    var html = this.MainRichTextEditor.GetHtmlText();
    await DisplayAlert("GetHtml()", html, "OK");
}

最終效果

已知問題

  • HTML樣式會重複新增

專案地址

我在maui-sample專案中的一些控制元件,打算做成一個控制元件庫,方便大家使用。控制元件庫地址在下方。

maui-sample專案作為控制元件庫孵化器,程式碼可能會有點亂,也沒有經過嚴格的測試。當控制元件完善到一定程度,我會把控制元件封裝起來放到控制元件庫中。如果你有好的控制元件,歡迎pull request。

maui-sample:
Github:maui-samples

Mato.Maui控制元件庫
Mato.Maui