[MAUI程式設計]介面多型與實現

2023-05-14 18:00:08


.NET MAUI 實現介面多型有很多種方式,今天主要來說說在日常開發中常見的需求該如何應對。

需求一:在不同裝置上使用不同 UI 外觀

.NET MAUI是一個跨平臺的UI框架,可在一個專案中開發Android、iOS、Windows、MacOS等多個平臺的應用。在不同裝置上我們希望應用的介面或互動方式能夠有所不同。

比如在本範例中,我們希望部落格條目的選單使用平臺特性的互動方式以方便觸屏或滑鼠的操作:比如手機裝置中部落格條目的選單使用側滑方式呈現,而在桌面裝置中使用右鍵選單呈現。

要實現不同平臺下的控制元件外觀,我們可以定義一個ContentView控制元件,然後在不同平臺上使用不同的控制元件模板(ControlTemplate)

控制元件模板(ControlTemplate)是我們的老朋友了,早在WPF時代就已經出現了,它可以完全改變一個控制元件的可視結構和外觀,與使用Style改變控制元件外觀樣式和行為樣式不同,使用Style只能改變控制元件已有的屬性。

定義控制元件 UI 外觀

首先用控制元件模板定義部落格條目的外觀,「部落格條目」是包含部落格標題,內容,以及釋出時間等資訊的卡片,視覺上呈現圓角矩形的白色不透明卡片效果。

部落格條目控制元件是一個基於ContentView控制元件

<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:service="clr-namespace:Lession1.Models;assembly=Lession1"
             x:Class="Lession1.Views.TextBlogView">
    <Grid>
       <VerticalStackLayout>
            <Label Text="{Binding Title}" FontAttributes="Bold">
            </Label>
            <Label Text="{Binding Content}"
                   LineBreakMode="WordWrap"></Label>
        </VerticalStackLayout>
    </Grid>
</ContentView>

在頁面的資源中,新增如下兩個ControlTemplate模板,分別用於手機裝置和桌面裝置。

  1. BlogCardViewPhone用於部落格條目在手機裝置中的呈現,條目選單側滑欄方式展開,我們設定SwipeView控制元件,作為卡片,用一個Frame框架包裹其內容。設定卡片的陰影,圓角,以及內邊距。

程式碼如下

<ControlTemplate x:Key="BlogCardViewPhone">
    <Grid>
        <SwipeView>
            <SwipeView.LeftItems>
                <SwipeItems>
                    <SwipeItem Text="編輯"
                                IconImageSource="delete.png"
                                BackgroundColor="LightGray" />
                    <SwipeItem Text="分享"
                                IconImageSource="delete.png"
                                BackgroundColor="LightGray" />
                    <SwipeItem Text="刪除"
                                IconImageSource="delete.png"
                                BackgroundColor="Red" />

                </SwipeItems>
            </SwipeView.LeftItems>
            <Frame  HasShadow="True"
                    Margin="0,10,0,10"
                    CornerRadius="5"
                    Padding="8">
                <VerticalStackLayout>
                    <ContentPresenter />
                    <Label Text="{TemplateBinding BindingContext.PostTime}"
                            FontFamily="FontAwesome"></Label>
                    <Button Text="編輯/釋出"
                            Command="{TemplateBinding BindingContext.SwitchState}" />
                </VerticalStackLayout>
            </Frame>
        </SwipeView>
    </Grid>
</ControlTemplate>
  1. BlogCardViewDesktop用於部落格條目在桌面裝置中的呈現,條目選單右鍵選單方式展開,我們設定FlyoutBase.ContextFlyout屬性,作為卡片,用一個Frame框架包裹其內容。設定卡片的陰影,圓角,以及內邊距。

程式碼如下:

<ControlTemplate x:Key="BlogCardViewDesktop">
    <Frame HasShadow="True"
            Margin="0,10,0,10"
            CornerRadius="5"
            Padding="8">
        <FlyoutBase.ContextFlyout>
            <MenuFlyout>
                <MenuFlyoutItem Text="編輯" />
                <MenuFlyoutItem Text="分享" />
                <MenuFlyoutItem Text="刪除" />
            </MenuFlyout>
        </FlyoutBase.ContextFlyout>

        <VerticalStackLayout>
            <ContentPresenter  />
            <Label Text="{TemplateBinding BindingContext.PostTime}"
                    FontFamily="FontAwesome"></Label>
            <Button Text="編輯/釋出"
                    Command="{TemplateBinding BindingContext.SwitchState}" />
        </VerticalStackLayout>
    </Frame>

</ControlTemplate>

.NET MAUI 提供了ContentPresenter作為模板控制元件中的內容預留位置,用於標記模板化自定義控制元件或模板化頁面要顯示的內容將在何處顯示。

各平臺模板中的<ContentPresenter /> 將顯示控制元件的Content屬性,也就是將TextBlogView中定義的內容,放到ContentPresenter處。

<view:TextBlogView ControlTemplate="{StaticResource BlogCardViewPhone}">
</view:TextBlogView>

基於平臺自定義設定

.NET MAUI 提供了 OnPlatform 標記擴充套件和 OnIdiom 標記擴充套件。以便在不同平臺上使用不同的控制元件模板。

通過 OnPlatform 標記擴充套件可基於每個平臺控制元件屬性:

屬性 描述
Default 平臺的屬性的預設值。
Android 屬性在 Android 上應用的值。
iOS 屬性在 iOS 上應用的值。
MacCatalyst 設定為要在 Mac Catalyst 的值。
Tizen 屬性在 Tizen 平臺的值。
WinUI 屬性在 WinUI 的值。

通過 OnIdiom 標記擴充套件可基於裝置語意上的控制元件屬性

屬性 描述
Default 裝置語意的屬性的預設值。
Phone 屬性在手機上應用的值。
Tablet 屬性在平板電腦的值。
Desktop 設定為要在桌面平臺的值。
TV 屬性在電視平臺的值。
Watch 屬性在可穿戴裝置(手錶)平臺的值。

在本範例中,我們使用OnIdiom標記擴充套件,分別為手機和桌面裝置設定不同的模板。

<view:TextBlogView>
    <view:TextBlogView.ControlTemplate>
        <OnIdiom Phone="{StaticResource BlogCardViewPhone}"
                    Desktop="{StaticResource BlogCardViewDesktop}">
        </OnIdiom>
    </view:TextBlogView.ControlTemplate>
</view:TextBlogView>

需求二:在不同資料類別中使用不同的 UI 外觀

資料模板(DataTemplate) 可以在支援的控制元件上(如:CollectionView)定義資料表示形式
可以使用資料模板選擇器(DataTemplateSelector) 來實現更加靈活的模板選擇。

DataTemplateSelector 可用於在執行時根據資料繫結屬性的值來選擇 DataTemplate。 這樣可將多個 DataTemplate 應用於同一型別的物件,以自定義特定物件的外觀。

相對於ControlTemplate方式,DataTemplateSelector是從Xamarin.Forms 2.1引入的新特性。

定義檢視 UI 外觀

建立兩種檢視和模板選擇器:

  • TextBlog: 文字部落格
  • PhotoBlog: 圖片部落格

編寫文字部落格條目展示標題和內容,建立TextBlogView.xaml,定義如下:

<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:service="clr-namespace:Lession1.Models;assembly=Lession1"
             x:Class="Lession1.Views.TextBlogView">
    <Grid>
       <VerticalStackLayout>
            <Label Text="{Binding Title}" FontAttributes="Bold">
            </Label>
            <Label Text="{Binding Content}"
                   LineBreakMode="WordWrap"></Label>
        </VerticalStackLayout>
    </Grid>
</ContentView>

編寫圖片部落格條目展示標題和部落格中圖片的縮圖,建立PhotoBlogView.xaml,定義如下:

<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:service="clr-namespace:Lession1.Models;assembly=Lession1"
             x:Class="Lession1.Views.PhotoBlogView">
    <Grid>
        <Label Text="{Binding Title}"
                FontAttributes="Bold">
        </Label>
        <StackLayout BindableLayout.ItemsSource="{Binding Images}"
                        Orientation="Horizontal">
            <BindableLayout.ItemTemplate>
                <DataTemplate>
                    <Image Source="{Binding}"
                            Aspect="AspectFill"
                            WidthRequest="44"
                            HeightRequest="44" />
                </DataTemplate>
            </BindableLayout.ItemTemplate>
        </StackLayout>
    </Grid>
</ContentView>

建立資料模板

在頁面的資源中,新增各個檢視建立資料模板(DataTemplate)型別的資源。

<ContentPage.Resources>
    <DataTemplate x:Key="PhotoBlog">
        <view:PhotoBlogView>
            <view:PhotoBlogView.ControlTemplate>
                <OnIdiom Phone="{StaticResource BlogCardViewPhone}"
                            Tablet="{StaticResource BlogCardViewDesktop}"
                            Desktop="{StaticResource BlogCardViewDesktop}">
                </OnIdiom>
            </view:PhotoBlogView.ControlTemplate>
        </view:PhotoBlogView>
    </DataTemplate>
    <DataTemplate x:Key="TextBlog">
        <view:TextBlogView>
            <view:TextBlogView.ControlTemplate>

                <OnIdiom Phone="{StaticResource BlogCardViewPhone}"
                            Tablet="{StaticResource BlogCardViewDesktop}"
                            Desktop="{StaticResource BlogCardViewDesktop}">
                </OnIdiom>

            </view:TextBlogView.ControlTemplate>
        </view:TextBlogView>
    </DataTemplate>
  
</ContentPage.Resources>

建立選擇器

建立BlogDataTemplateSelector,根據部落格的型別,返回不同的資料模板(DataTemplate)物件。

public class BlogDataTemplateSelector : DataTemplateSelector
{
    public object ResourcesContainer { get; set; }

    protected override DataTemplate OnSelectTemplate(object item, BindableObject container)
    {
        if (item == null)
        {
            return default;
        }
        if (item is Blog)
        {
            var dataTemplateName = (item as Blog).Type;
            if (dataTemplateName == null) { return default; }
            if (ResourcesContainer == null)
            {
                return Application.Current.Resources[dataTemplateName] as DataTemplate;
            }
            return (ResourcesContainer as VisualElement).Resources[dataTemplateName] as DataTemplate;

        }
        return default;

    }
}

DataTemplate將在頁面資源字典中被建立。若沒有繫結ResourcesContainer,則在App.xaml中尋找。

同樣, 將BlogDataTemplateSelector新增到頁面的資源中

  <view:BlogDataTemplateSelector x:Key="BlogDataTemplateSelector"
ResourcesContainer="{x:Reference MainContentPage}" />

注意,此時BlogDataTemplateSelector.ResourcesContainer指向MainContentPage,顯式設定MainPage物件的名稱:x:Name="MainContentPage"

定義資料

我們定義一個Blog類, 用於表示部落格條目,包含標題,內容,釋出時間,圖片等屬性。

public class Blog 
{
    public Blog()
    {
        PostTime = DateTime.Now;
        State = BlogState.Edit;
    }

    
    public Guid NoteId { get; set; }
    public string Title { get; set; }
    public string Type { get; set; }
    public BlogState State { get; set; }
    public string Content { get; set; }
    public List<string> Images { get; set; }
    public DateTime PostTime { get; set; }
    public bool IsHidden { get; set; }
}

定義部落格列表的繫結資料來源 ObservableCollection<Blog> Blogs,給資料來源初始化一些資料,用於測試。

private async void CreateBlogAction(object obj)
{
    var type = obj as string;
    if (type == "TextBlog")
    {
        var blog = new Blog()
        {
            NoteId = Guid.NewGuid(),
            Title = type + " Blog",
            Type = type,
            Content = type + " Blog Test, There are so many little details that a software developer must take care of before publishing an application. One of the most time-consuming is the task of adding icons to your toolbars, buttons, menus, headers, footers and so on.",
            State = BlogState.PreView,
            IsHidden = false,

        };
        this.Blogs.Add(blog);

    }
    else if (type == "PhotoBlog")
    {
        var blog = new Blog()
        {
            NoteId = Guid.NewGuid(),
            Title = type + " Blog",
            Type = type,
            Content = type + " Blog Test",
            Images = new List<string>() { "p1.png", "p2.png", "p3.png", "p4.png" },
            State = BlogState.PreView,
            IsHidden = false,
        };
        this.Blogs.Add(blog);
    }
}

設定部落格列表控制元件CollectionView繫結的資料來源為Blogs,並設定資料模板選擇器為BlogDataTemplateSelector

<ContentPage.Content>
    <Grid Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="auto" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>
        <!--標題區域-->
        <Label Text="My Blog"
                TextColor="SlateGray"
                FontSize="Header"
                FontAttributes="Bold"></Label>
        <CollectionView x:Name="MainCollectionView"
                        Grid.Row="1"
                        ItemsSource="{Binding Blogs}"
                        ItemTemplate="{StaticResource BlogDataTemplateSelector}" />


    </Grid>
</ContentPage.Content>

則列表中的每個部落格條目將根據部落格型別,使用不同的資料模板進行渲染。

效果如下:

需求三:在不同資料狀態中使用不同的 UI 外觀

此功能沒有一個固定的解決方案,可以根據實際情況,選擇合適的方式實現。

比如在本專案中,部落格存在編輯和釋出兩個狀態

public enum BlogState
{
    Edit,
    PreView
}

使用繫結模型更改控制元件的外觀

最簡單的方式是用IsVisible來控制控制元件中元素的顯示和隱藏。

在本範例中,TextBlogView需要對編輯中的狀態和預覽中的狀態進行區分。
EnumToBoolConverter是列舉到bool值的轉換器,它返回當前繫結物件的State屬性與指定的BlogState列舉項是否一致,詳情請檢視 .NET MAUI 社群工具包

<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:service="clr-namespace:Lession1.Models;assembly=Lession1"
             x:Class="Lession1.Views.TextBlogView">
    <Grid>
        <VerticalStackLayout IsVisible="{Binding State, Converter={StaticResource EnumToBoolConverter}, ConverterParameter={x:Static service:BlogState.PreView}}">
            <Label Text="{Binding Title}" FontAttributes="Bold">
            </Label>
            <Label Text="{Binding Content}"
                   LineBreakMode="WordWrap"></Label>
        </VerticalStackLayout>

        <VerticalStackLayout IsVisible="{Binding State, Converter={StaticResource EnumToBoolConverter}, ConverterParameter={x:Static service:BlogState.Edit}}">
            <Label Text="編輯"
                   FontAttributes="Bold">
            </Label>
            <Entry Text="{Binding Title}"
                   Placeholder="標題"></Entry>
            <Editor Text="{Binding Content}"
                    AutoSize="TextChanges"
                    Placeholder="內容"></Editor>
        </VerticalStackLayout>
    </Grid>

</ContentView>

編輯狀態:

釋出狀態:

使用視覺狀態更改控制元件的外觀

還可以使用定義自定義視覺狀態對介面進行控制。

在本範例中,使用VisualStateManager定義了兩個視覺狀態,分別對應Label的編輯狀態和釋出狀態,當State屬性的值發生變化時,會觸發對應的視覺狀態。

<Label Grid.Row="1">
    <VisualStateManager.VisualStateGroups>
        <VisualStateGroup>
            <VisualState Name="BlogPreView">
                <VisualState.StateTriggers>
                    <StateTrigger
                                    IsActive="{Binding State, Converter={StaticResource EnumToBoolConverter}, ConverterParameter={x:Static service:BlogState.PreView}}" />
                </VisualState.StateTriggers>
                <VisualState.Setters>
                    <Setter Property="Text"
                            Value="當前為釋出模式" />

                </VisualState.Setters>
            </VisualState>
            <VisualState Name="BlogEdit">
                <VisualState.StateTriggers>
                    <StateTrigger IsActive="{Binding State, Converter={StaticResource EnumToBoolConverter}, ConverterParameter={x:Static service:BlogState.Edit}}" />
                </VisualState.StateTriggers>
                <VisualState.Setters>
                    <Setter Property="Text"
                            Value="當前為編輯模式" />

                </VisualState.Setters>
            </VisualState>
        </VisualStateGroup>
    </VisualStateManager.VisualStateGroups>

</Label>

編輯狀態:

釋出狀態:

專案地址

Github:maui-learning

關注我,學習更多.NET MAUI開發知識!