[MAUI]模仿網易雲音樂黑膠唱片的互動實現

2023-04-25 12:03:47

@


用過網易雲音樂App的同學應該都比較熟悉它播放介面。

這是一個良好的互動設計,留聲機的介面隱喻準確地向人們傳達產品概念和使用方法:當手指左右滑動時,便模擬了更換唱盤從而導向切換歌曲的互動功能。

今天在 .NET MAUI 中我們來實現這個互動效果,先來看看效果:

使用.NET MAU實現跨平臺支援,本專案可執行於Android、iOS平臺。

建立頁面佈局

專案模擬了網易雲音樂的播放主介面,可播放本地音樂檔案。使用MatoMusic.Core作為播放核心,此專案對其將不再贅述。請閱讀此博文[MAUI 專案實戰] 音樂播放器(二):播放核心

新建.NET MAUI專案,命名CloudMusicGroove,專案參照MatoMusic.Core。

將介面圖片資原始檔拷貝到專案\Resources\Images中,這些介面圖片資源可通過解包官方apk的方式輕鬆獲取。

將他們包含在MauiImage資源清單中。

<MauiImage Include="Resources\Images\*" />

建立頁面的靜態佈局,佈局如下圖所示

其中唱盤元素是一個300 × 300的圓形,專輯封面為200 × 200的圓形,圖片的圓形區域是通過裁剪實現的,程式碼如下:

<Grid 
        VerticalOptions="Start"
        HorizontalOptions="Start">
    <Image Source="ic_disc.png"
            WidthRequest="300"
            HeightRequest="300" />

    <Image HeightRequest="200"
            WidthRequest="200"
            x:Name="AlbumArtImage"
            Margin="0"
            Source="{Binding  CurrentMusic.AlbumArt}"
            VerticalOptions="CenterAndExpand"
            HorizontalOptions="CenterAndExpand"
            Aspect="AspectFill">

        <Image.Clip>
            <RoundRectangleGeometry  CornerRadius="125"
                                        Rect="0,0,200,200" />
        </Image.Clip>
    </Image>

</Grid>

設定留聲機唱針元素,程式碼如下:

<Image WidthRequest="100"
    HeightRequest="167"
    HorizontalOptions="Center"
    VerticalOptions="Start"
    Margin="70,-50,0,0"
    Source="ic_needle.png"
    x:Name="AlbumNeedle" />

建立PitContentLayout區域,這個區域是一個3 × 2的網格佈局,用來放置三個功能區域

在PitContentLayout中建立三個PitGrid控制元件,並對這三個功能區域的PitGrid控制元件命名,LeftPitMiddlePitRightPit,程式碼如下:

<Grid  x:Name="PitContentLayout"
        Opacity="1"
        BindingContext="{Binding CurrentMusicRelatedViewModel}">
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="1*"></ColumnDefinition>
        <ColumnDefinition Width="2*"></ColumnDefinition>
        <ColumnDefinition Width="1*"></ColumnDefinition>
    </Grid.ColumnDefinitions>
    <controls1:PitGrid x:Name="LeftPit"
                        Background="pink"
                        PitName="LeftPit">
    </controls1:PitGrid>
    <controls1:PitGrid Grid.Column="1"
                        x:Name="MiddlePit"
                        Background="azure"
                        
                        PitName="MiddlePit">
    </controls1:PitGrid>
    <controls1:PitGrid Grid.Column="2"
                        x:Name="RightPit"
                        Background="lightyellow"
                        PitName="RightPit">

    </controls1:PitGrid>


</Grid>

建立手勢控制元件

手勢控制元件,或稱為手勢容器控制元件,它來對拖拽物進行包裝,以賦予拖拽物響應平移手勢的能力。

建立一個容器控制元件HorizontalPanContainer,控制元件包含的PanGestureRecognizer提供了當手指在螢幕移動這一過程的描述

<?xml version="1.0" encoding="UTF-8"?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui" 
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MauiSample.Controls.HorizontalPanContainer">
    <ContentView.GestureRecognizers>
        <PanGestureRecognizer PanUpdated="PanGestureRecognizer_OnPanUpdated"></PanGestureRecognizer>
        <TapGestureRecognizer Tapped="TapGestureRecognizer_OnTapped"></TapGestureRecognizer>

    </ContentView.GestureRecognizers>
</ContentView>

建立一個手勢控制元件。他將留聲機唱盤區域包裹起來。這樣當手指在唱盤區域滑動時,就可以觸發平移手勢事件。

<controls:HorizontalPanContainer Background="Transparent"
        x:Name="DefaultPanContainer"
        OnTapped="DefaultPanContainer_OnOnTapped"
        OnfinishedChoise="DefaultPanContainer_OnOnfinishedChoise">
    <controls:HorizontalPanContainer.Content>
        <Grid PropertyChanged="BindableObject_OnPropertyChanged"
                VerticalOptions="Start"
                HorizontalOptions="Start">
            <Image Source="ic_disc.png"
                    WidthRequest="300"
                    HeightRequest="300" />

            <Image HeightRequest="200"
                    WidthRequest="200"
                    x:Name="AlbumArtImage"
                    Margin="0"
                    Source="{Binding  CurrentMusic.AlbumArt}"
                    VerticalOptions="CenterAndExpand"
                    HorizontalOptions="CenterAndExpand"
                    Aspect="AspectFill">

                <Image.Clip>
                    <RoundRectangleGeometry  CornerRadius="125"
                                                Rect="0,0,200,200" />
                </Image.Clip>
            </Image>

        </Grid>

    </controls:HorizontalPanContainer.Content>
</controls:HorizontalPanContainer>

建立影子控制元件

影子控制元件用於滑動唱盤時,顯示上一曲、下一曲的專輯封面。

在左右滑動的全程中,唱盤的中心點與相鄰唱盤的中心點距離,應為螢幕寬度。如下圖所示

唱盤與唱盤的距離應是

建立影子控制元件,這個控制元件將隨拖拽物的移動而跟隨移動,當然我們只需要保持X方向的移動即可。

在NowPlayingPage中的HorizontalPanContainer相鄰容器檢視中建立影子控制元件,程式碼如下:

<Grid TranslationX="{Binding Source={x:Reference  DefaultPanContainer} ,Path=Content.TranslationX}">
    <Image Source="ic_disc.png"
            WidthRequest="300"
            HeightRequest="300" />

    <Image HeightRequest="200"
            WidthRequest="200"
            Margin="0"
            Source="{Binding  PreviewMusic.AlbumArt}"
            VerticalOptions="CenterAndExpand"
            HorizontalOptions="CenterAndExpand"
            Aspect="AspectFill">

        <Image.Clip>
            <RoundRectangleGeometry  CornerRadius="125"
                                        Rect="0,0,200,200" />
        </Image.Clip>
    </Image>

</Grid>

我們將這個影子控制元件的TranslationX屬性將繫結到拖拽物的TranslationX屬性上,初步效果如下

拖拽區域需要兩個影子控制元件,分別顯示上一曲和下一曲的專輯封面。

我們需要將影子控制元件的偏移量與螢幕寬度作匹配,我們用轉換器來實現這個功能。

建立CalcValueConverter.cs檔案,程式碼如下:

public class CalcValueConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        var d = (double)value;
        double compensation;
        if (double.Parse((string)parameter)>=0)
        {
            compensation=((App.Current as App).PanContainerWidth+300)/2;
        }
        else
        {
            compensation=-1.5*(App.Current as App).PanContainerWidth+300/2;
        }
        return d+compensation;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }

}

將CalcValueConverter新增至資源字典中,

<converter:CalcValueConverter x:Key="CalcValueConverter"></converter:CalcValueConverter>

對影子控制元件的屬性繫結設定轉換器,並設定轉換器引數,程式碼如下:

左影子控制元件(上一曲專輯唱盤)

TranslationX="{Binding Source={x:Reference  DefaultPanContainer} ,Path=Content.TranslationX,Converter={StaticResource CalcValueConverter},ConverterParameter=-1}"

右影子控制元件(下一曲專輯唱盤)

TranslationX="{Binding Source={x:Reference  DefaultPanContainer} ,Path=Content.TranslationX,Converter={StaticResource CalcValueConverter},ConverterParameter=-1}"

唱盤撥動互動

當然我們僅希望拖拽物僅在水平方向上響應手勢

在HorizontalPanContainer中,註冊PanGestureRecognizer的響應事件PanGestureRecognizer_OnPanUpdated,在GestureStatus.Running新增程式碼如下:

private async void PanGestureRecognizer_OnPanUpdated(object sender, PanUpdatedEventArgs e)
{
    var isInPit = false;
    switch (e.StatusType)
    {
        case GestureStatus.Running:
            var translationX = PositionX + e.TotalX;
            var translationY = PositionY;

        ...
    }
}

結合上一小節寫的三個PitGrid,此時拖拽唱盤,並且在拖拽開始,進入pit,離開pit,釋放時,分別觸發Start,In,Out,Over四個狀態事件。

響應狀態事件的有效區域如下

建立檢測唱盤中心點是否在有效區域的方法,

當平移方向為向右時,唱盤中心點的X座標應大於右pit區域的起始X座標;
當平移方向為向左時,唱盤中心點的X座標應小於左pit區域的結束X座標。

在GestureStatus.Running新增程式碼如下:


foreach (var item in PitLayout)
{
    var pitRegion = new Region(item.X, item.X + item.Width, item.Y, item.Y + item.Height, item.PitName);
    var isXin = (e.TotalX>0 && translationX >= pitRegion.StartX - Content.Width / 2 && pitRegion.StartX>this.Width/2)||
        (e.TotalX<0 && translationX <= pitRegion.EndX - Content.Width / 2&&pitRegion.EndX<this.Width/2);
    if (isXin)
    {
        isInPit = true;      
    }
    ...
}

在不同的pit中,處理對應的狀態事件。

若在手指離開時,唱盤的中心點還在MiddlePit區域範圍內,則將唱盤迴彈移動到MiddlePit中心點。

若在LeftPit或RightPit區域,則將唱盤移動到LeftPit或RightPit區域中心點。

此時已經實現了拖拽唱盤的基本功能,但是在釋放唱盤時,影子唱盤並沒有如預期那樣移動到MiddlePit的中心點。

當命中LeftPit或RightPit區域時,我們希望影子控制元件移動到MiddlePit中心點。當影子控制元件移動到位時,替換掉當前的唱盤,成為新的拖拽物。由此可以無限的撥動唱盤實現連續切歌的效果。

當手指釋放,唱盤準備向左或右移動時,迅速將影子控制元件的位置替換成當前唱盤的位置。用當前唱盤的「瞬移」,看起來像唱盤被影子唱盤替換掉了,但是在螢幕中心活動的拖拽物,一直是真正的那個控制元件。

在GestureStatus.Completed新增程式碼如下:

case GestureStatus.Completed:
    double destinationX;
    var view = this.CurrentView;

    if (isInPitPre)
    {
        var pitRegion = new Region(view.X, view.X + view.Width, view.Y, view.Y + view.Height, view.PitName);

        var prefix = pitRegion.StartX>this.Width/2 ? 1 : -1;
        destinationX=PositionX+prefix*(App.Current as App).PanContainerWidth;
    }
    else
    {
        destinationX=PositionX;

    }

這樣看起來像可以無限地撥動唱盤了

唱盤和唱針動畫

唱盤轉動,音樂隨之播放,通過將專輯封面圖片以20秒每圈的速度旋轉來實現唱盤旋轉的效果。

在NowPlayingPage中建立一個Animation物件,用於控制唱盤旋轉。

private Animation rotateAnimation;

編寫啟動旋轉動畫方法StartAlbumArtRotation以及停止動畫方法StopAlbumArtRotation,程式碼如下:

private void StartAlbumArtRotation()
{
    this.AlbumArtImage.AbortAnimation("AlbumArtImageAnimation");
    rotateAnimation = new Animation(v => this.AlbumArtImage.Rotation = v, this.AlbumArtImage.Rotation, this.AlbumArtImage.Rotation+ 360);
    rotateAnimation.Commit(this, "AlbumArtImageAnimation", 16, 20*1000, repeat: () => true);
}

private void StopAlbumArtRotation()
{
    this.AlbumArtImage.CancelAnimations();
    if (this.rotateAnimation!=null)
    {
        this.rotateAnimation.Dispose();
    }

}

效果如下:

注意,當音樂暫停後,停止旋轉動畫,當音樂恢復播放時,轉盤應從之前停止的角度開始啟動旋轉動畫。

在撥動唱盤或切歌時,唱針將從唱盤上移開,通過旋轉唱針圖片30度來實現唱針移開的效果。

首先設定錨點,AnchorX=0.18,AnchorY=0.059,如下:

<Image WidthRequest="100"
    HeightRequest="167"
    HorizontalOptions="Center"
    VerticalOptions="Start"
    Margin="70,-50,0,0"
    Source="ic_needle.png"
    x:Name="AlbumNeedle"
    AnchorX="0.18"
    AnchorY="0.059" />

在音樂播放時
當手指開始滑動時,唱針從唱盤上移開,唱盤停止旋轉;
當手指離開時,唱針回到唱盤上,唱盤繼續旋轉。

private async void PanActionHandler(object recipient, HorizontalPanActionArgs args)
{
    switch (args.PanType)
    {
        case HorizontalPanType.Over:

            if (MusicRelatedViewModel.IsPlaying)
            {
                await this.AlbumNeedle.RotateTo(0, 300);
                this.StartAlbumArtRotation();
            }


            break;
        case HorizontalPanType.Start:

            if (MusicRelatedViewModel.IsPlaying)
            {
                await this.AlbumNeedle.RotateTo(-30, 300);
                this.StopAlbumArtRotation();
            }
            break;
        ...
    }
}

效果如下:

當暫停、恢復時,唱針的位置也應該隨之改變。

private async void MusicRelatedViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
    if (e.PropertyName==nameof(MusicRelatedViewModel.IsPlaying))
    {
        if (MusicRelatedViewModel.IsPlaying)
        {
            await this.AlbumNeedle.RotateTo(0, 300);
            this.StartAlbumArtRotation();
        }
        else
        {
            await this.AlbumNeedle.RotateTo(-30, 300);
            this.StopAlbumArtRotation();

        }

    }
}

效果如下:

最終效果如下:

專案地址

Github:maui-samples