Avalonia入門:音楽ストアアプリの構築

このチュートリアルでは、Avalonia公式ドキュメントの音楽ストアサンプルをもとに、MVVMパターンを用いたデスクトップアプリケーションの基本を学びます。 完成イメージは以下の通りです(画像は省略)。

1. プロジェクトテンプレートの作成

「Avalonia .NET Core MVVM App」テンプレートを使用してプロジェクトを作成します。 テンプレートが生成するフォルダとファイルの役割は次のとおりです。

  • Assets:画像、アイコン、フォントなど、アプリケーションに埋め込むリソースを格納します。
  • Models:MVVMの「モデル」層のコード(データアクセス、API連携など)を配置します。
  • ViewModels:ビューモデル(アプリケーションロジック)を格納します。初期状態でサンプルが含まれています。
  • Views:ビュー(レイアウト、フォーム、スタイルなど表示に関する部分)を配置します。メインウィンドウのビューが既に含まれています。

2. ウィンドウのスタイル設定

App.axaml を開き、<Application> 要素の RequestedThemeVariant 属性を Dark に変更します。

<Application ...
             RequestedThemeVariant="Dark">

次に MainWindow.axaml でウィンドウの透明度と背景を設定します。

<Window ...
        Title="music"
        TransparencyLevelHint="AcrylicBlur"
        Background="Transparent"
        ExtendClientAreaToDecorationsHint="True">

TransparencyLevelHint はウィンドウの透明効果を指定します(AcrylicBlur / Blur / None)。
ExtendClientAreaToDecorationsHintTrue にすることで、クライアント領域をタイトルバーや境界線まで拡張し、フレームレスウィンドウを実現します。

ウィンドウ全体にアクリル効果を適用するために、<Window> 内に <Panel><ExperimentalAcrylicBorder> を追加します。

<Window ...>
    <Panel>
        <ExperimentalAcrylicBorder IsHitTestVisible="False">
            <ExperimentalAcrylicBorder.Material>
                <ExperimentalAcrylicMaterial
                    BackgroundSource="Digger"
                    TintColor="Black"
                    TintOpacity="1"
                    MaterialOpacity="0.65" />
            </ExperimentalAcrylicBorder.Material>
        </ExperimentalAcrylicBorder>
    </Panel>
</Window>

3. アイコンリソースの追加と配置

プロジェクトに Styles (Avalonia) アイテムを追加し、ファイル名を icons.axaml とします。このファイルに、購入ボタン用のパスアイコンを <StreamGeometry> として定義します。

<Styles xmlns="https://github.com/avaloniaui">
    <Style>
        <Style.Resources>
            <StreamGeometry x:Key="purchase">...(パスデータ)...</StreamGeometry>
        </Style.Resources>
    </Style>
</Styles>

App.axaml で、作成したスタイルファイルを読み込みます。

<Application.Styles>
    <FluentTheme />
    <StyleInclude Source="avares://YourProjectName/icons.axaml"/>
</Application.Styles>

MainWindow.axaml<Panel> 内にボタンを追加し、先ほど定義したアイコンを表示します。

<Panel Margin="40">
    <Button HorizontalAlignment="Right" VerticalAlignment="Top">
        <PathIcon Data="{StaticResource purchase}" />
    </Button>
</Panel>

4. ボタンクリックの応答設定

MainWindowViewModel.cs で、ボタンに対応するコマンドを定義します。ReactiveUI の ReactiveCommand を使用します。

public class MainWindowViewModel : ViewModelBase
{
    public ReactiveCommand<Unit, Unit> PurchaseCommand { get; }

    public MainWindowViewModel()
    {
        PurchaseCommand = ReactiveCommand.Create(OnPurchase);
    }

    private void OnPurchase()
    {
        // 購入処理(後でダイアログを開く)
    }
}

ビュー(MainWindow.axaml)でコマンドをバインディングします。

<Button HorizontalAlignment="Right" VerticalAlignment="Top"
        Command="{Binding PurchaseCommand}">
    <PathIcon Data="{StaticResource purchase}"/>
</Button>

5. 子ウィンドウの表示

新しいウィンドウ(MusicStoreWindow)を Views フォルダに追加し、同様にアクリル効果を設定します。 また、ViewModels フォルダに MusicStoreViewModelAlbumViewModel クラスを作成します。

MainWindowViewModelInteraction を追加して、子ウィンドウ表示を処理します。

public class MainWindowViewModel : ViewModelBase
{
    public Interaction<MusicStoreViewModel, AlbumViewModel?> ShowDialog { get; }

    public MainWindowViewModel()
    {
        ShowDialog = new Interaction<MusicStoreViewModel, AlbumViewModel?>();
        PurchaseCommand = ReactiveCommand.Create(OpenStore);
    }

    private async void OpenStore()
    {
        var store = new MusicStoreViewModel();
        var result = await ShowDialog.Handle(store);
    }
}

MainWindow.axaml.cs で、ReactiveWindow<MainWindowViewModel> を継承し、WhenActivated 内でダイアログ表示処理を登録します。

public partial class MainWindow : ReactiveWindow<MainWindowViewModel>
{
    public MainWindow()
    {
        InitializeComponent();
        this.WhenActivated(action =>
            action(ViewModel!.ShowDialog.RegisterHandler(DoShowDialogAsync)));
    }

    private async Task DoShowDialogAsync(InteractionContext<MusicStoreViewModel, AlbumViewModel?> interaction)
    {
        var dialog = new MusicStoreWindow();
        dialog.DataContext = interaction.Input;
        var result = await dialog.ShowDialog<AlbumViewModel?>(this);
        interaction.SetOutput(result);
    }
}

6. 検索・表示用のUserControl

Views フォルダに MusicStoreViews ユーザーコントロール(UserControl)を作成します。レイアウトは DockPanel を使用し、検索ボックス、プログレスバー、アルバムリスト、購入ボタンを配置します。

<DockPanel>
    <StackPanel DockPanel.Dock="Top">
        <TextBox Watermark="検索キーワードを入力..." />
        <ProgressBar IsIndeterminate="True" />
    </StackPanel>
    <Button Content="アルバムを購入"
            DockPanel.Dock="Bottom"
            HorizontalAlignment="Center" />
    <ListBox />
</DockPanel>

MusicStoreWindow.axaml でこのユーザーコントロールを埋め込みます。

<Window ...
    xmlns:views="using:YourProject.Views">
    <Panel Margin="40">
        <views:MusicStoreViews/>
    </Panel>
</Window>

7. ビューモデルの実装とデータバインディング

MusicStoreViewModel を実装し、検索テキスト、選択アイテム、ビジー状態を公開します。検索結果は ObservableCollection<AlbumViewModel> で保持します。

public class MusicStoreViewModel : ViewModelBase
{
    private string? _query;
    private bool _isBusy;
    private AlbumViewModel? _selectedAlbum;

    public ObservableCollection<AlbumViewModel> SearchResults { get; } = new();

    public AlbumViewModel? SelectedAlbum
    {
        get => _selectedAlbum;
        set => this.RaiseAndSetIfChanged(ref _selectedAlbum, value);
    }

    public string? Query
    {
        get => _query;
        set => this.RaiseAndSetIfChanged(ref _query, value);
    }

    public bool IsBusy
    {
        get => _isBusy;
        set => this.RaiseAndSetIfChanged(ref _isBusy, value);
    }
}

AlbumView ユーザーコントロールを作成し、アルバムカバーとタイトル、アーティスト名を表示します。最初は音符アイコンをプレースホルダーとして表示します。

<StackPanel Spacing="5" Width="200">
    <Border CornerRadius="10" ClipToBounds="True">
        <Panel Background="#7FFF22DD">
            <Image Width="200" Stretch="Uniform" Source="{Binding Cover}" />
            <Panel Height="200" IsVisible="{Binding Cover, Converter={x:Static ObjectConverters.IsNull}}">
                <PathIcon Height="75" Width="75" Data="{StaticResource music_regular}" />
            </Panel>
        </Panel>
    </Border>
    <TextBlock HorizontalAlignment="Center" Text="{Binding Title}" />
    <TextBlock HorizontalAlignment="Center" Text="{Binding Artist}" />
</StackPanel>

8. データの取得と表示

NuGet パッケージ iTunesSearch をインストールし、Models/Album.cs で検索ロジックと画像のキャッシュ処理を実装します。

public class Album
{
    private static readonly iTunesSearchManager SearchManager = new();
    private static readonly HttpClient HttpClient = new();
    private string CachePath => $"./Cache/{Artist} - {Title}";

    public string Artist { get; set; }
    public string Title { get; set; }
    public string CoverUrl { get; set; }

    public Album(string artist, string title, string coverUrl)
    {
        Artist = artist;
        Title = title;
        CoverUrl = coverUrl;
    }

    public static async Task<IEnumerable<Album>> SearchAsync(string searchTerm)
    {
        var result = await SearchManager.GetAlbumsAsync(searchTerm).ConfigureAwait(false);
        return result.Albums.Select(x =>
            new Album(x.ArtistName, x.CollectionName,
                      x.ArtworkUrl100.Replace("100x100bb", "600x600bb")));
    }

    public async Task<Stream> LoadCoverAsync()
    {
        if (File.Exists(CachePath + ".bmp"))
            return File.OpenRead(CachePath + ".bmp");

        var bytes = await HttpClient.GetByteArrayAsync(CoverUrl);
        return new MemoryStream(bytes);
    }
}

AlbumViewModelLoadCover メソッドを呼び出し、画像をビットマップとして設定します。

public class AlbumViewModel : ViewModelBase
{
    private readonly Album _album;
    private Bitmap? _cover;

    public AlbumViewModel(Album album) => _album = album;

    public Bitmap? Cover
    {
        get => _cover;
        private set => this.RaiseAndSetIfChanged(ref _cover, value);
    }

    public string Artist => _album.Artist;
    public string Title => _album.Title;

    public async Task LoadCover()
    {
        await using var stream = await _album.LoadCoverAsync();
        Cover = await Task.Run(() => Bitmap.DecodeToWidth(stream, 400));
    }
}

MusicStoreViewModel の検索処理では、WhenAnyValueThrottle で入力のデバウンスを行い、キャンセルトークンで途中の検索をキャンセルします。

private CancellationTokenSource? _cts;

public MusicStoreViewModel()
{
    this.WhenAnyValue(x => x.Query)
        .Throttle(TimeSpan.FromMilliseconds(400))
        .ObserveOn(RxApp.MainThreadScheduler)
        .Subscribe(DoSearch!);
}

private async void DoSearch(string? query)
{
    IsBusy = true;
    SearchResults.Clear();
    _cts?.Cancel();
    _cts = new CancellationTokenSource();
    var token = _cts.Token;

    if (!string.IsNullOrWhiteSpace(query))
    {
        var albums = await Album.SearchAsync(query);
        foreach (var album in albums)
        {
            var vm = new AlbumViewModel(album);
            SearchResults.Add(vm);
        }

        if (!token.IsCancellationRequested)
            LoadCovers(token);
    }
    IsBusy = false;
}

private async void LoadCovers(CancellationToken token)
{
    foreach (var album in SearchResults.ToList())
    {
        await album.LoadCover();
        if (token.IsCancellationRequested) return;
    }
}

最後に、MusicStoreViews.axaml のバインディングを設定します。

<TextBox Text="{Binding Query}" Watermark="検索..." />
<ProgressBar IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
<ListBox ItemsSource="{Binding SearchResults}"
         SelectedItem="{Binding SelectedAlbum}"
         Background="Transparent" Margin="0,20">
    <ListBox.ItemsPanel>
        <ItemsPanelTemplate>
            <WrapPanel />
        </ItemsPanelTemplate>
    </ListBox.ItemsPanel>
</ListBox>

これで、検索とアルバムカバーの表示が可能な音楽ストアダイアログが完成します。

タグ: Avalonia MVVM ReactiveUI .NET Core XAML

6月29日 19:38 投稿