用过网易云音乐App的同学应该都比较熟悉它播放界面。
这是一个良好的交互设计,留声机的界面隐喻准确地向人们传达产品概念和使用方法:当手指左右滑动时,便模拟了更换唱盘从而导向切换歌曲的交互功能。
今天在 .NET MAUI 中我们来实现这个交互效果,先来看看效果:
使用.NET MAU 实现跨平台支持,本项目可运行于Android、iOS平台。
创建页面布局 项目模拟了网易云音乐的播放主界面,可播放本地音乐文件。使用MatoMusic.Core作为播放内核,此项目对其将不再赘述。请阅读此博文[MAUI 项目实战] 音乐播放器(二):播放内核
新建.NET MAUI项目,命名CloudMusicGroove
,项目引用MatoMusic.Core。
将界面图片资源文件拷贝到项目\Resources\Images中,这些界面图片资源可通过解包官方apk的方式轻松获取。
将他们包含在MauiImage资源清单中。
1 <MauiImage Include="Resources\Images\*" />
创建页面的静态布局,布局如下图所示
其中唱盘元素是一个300 × 300的圆形,专辑封面为200 × 200的圆形,图片的圆形区域是通过裁剪实现的,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 <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 >
设置留声机唱针元素,代码如下:
1 2 3 4 5 6 7 8 <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控件命名,LeftPit
、MiddlePit
,RightPit
,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 <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提供了当手指在屏幕移动这一过程的描述
1 2 3 4 5 6 7 8 9 10 <?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>
创建一个手势控件。他将留声机唱盘区域包裹起来。这样当手指在唱盘区域滑动时,就可以触发平移手势事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 <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相邻容器视图中创建影子控件,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <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文件,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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添加至资源字典中,
1 <converter:CalcValueConverter x:Key="CalcValueConverter"></converter:CalcValueConverter>
对影子控件的属性绑定设置转换器,并设置转换器参数,代码如下:
左影子控件(上一曲专辑唱盘)
1 TranslationX="{Binding Source={x:Reference DefaultPanContainer} ,Path=Content.TranslationX,Converter={StaticResource CalcValueConverter},ConverterParameter=-1}"
右影子控件(下一曲专辑唱盘)
1 TranslationX="{Binding Source={x:Reference DefaultPanContainer} ,Path=Content.TranslationX,Converter={StaticResource CalcValueConverter},ConverterParameter=-1}"
唱盘拨动交互 当然我们仅希望拖拽物仅在水平方向上响应手势
在HorizontalPanContainer中,注册PanGestureRecognizer的响应事件PanGestureRecognizer_OnPanUpdated,在GestureStatus.Running添加代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 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添加代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 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添加代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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对象,用于控制唱盘旋转。
1 2 private Animation rotateAnimation;
编写启动旋转动画方法StartAlbumArtRotation以及停止动画方法StopAlbumArtRotation,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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,如下:
1 2 3 4 5 6 7 8 9 <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" />
在音乐播放时 当手指开始滑动时,唱针从唱盘上移开,唱盘停止旋转; 当手指离开时,唱针回到唱盘上,唱盘继续旋转。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 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; ... } }
效果如下:
当暂停、恢复时,唱针的位置也应该随之改变。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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