しっぽを追いかけて

ぐるぐるしながら考えています

Unity と猫の話題が中心   掲載内容は個人の私見であり、所属組織の見解ではありません

Xamarin.Forms で Microsoft Band にアプリタイルを登録する

Microsoft Band にはスマホ側のアプリごとにタイルを登録できるようなので試してみます

ソースコードの一式は下記にあります!

細かい実装などはこちらを参照ください

github.com

※ 順次改修していく予定なので、この記事の内容が現時点のソースより古い可能性があります


ソリューションはこんな感じになりました

f:id:matatabi_ux:20150503175932p:plain

タイル管理でも BandTile や BandIcon など継承も直接インスタンス化もできないクラスを API でやりとりするので、共通プロジェクト側にインターフェースを追加して抽象化しました

あとは BandIcon を Xamarin.Forms と各プラットフォーム間で変換するために NativeBandImageConvert クラスを拡張しました

/// <summary>
/// iOS 用画像コンバーター
/// </summary>
public static class NativeBandImageConvert
{
    /// <summary>
    /// 画像の変換
    /// </summary>
    /// <param name="image">画像情報</param>
    /// <returns>画像ソース</returns>
    public static StreamImageSource FromNative(Native.Personalization.BandImage image)
    {
        return (StreamImageSource)ImageSource.FromStream(image.UIImage.AsPNG().AsStream);
    }

    /// <summary>
    /// 画像の変換
    /// </summary>
    /// <param name="source">画像ソース</param>
    /// <returns>画像情報</returns>
    public static async Task<Native.Personalization.BandImage> ToNative(StreamImageSource source)
    {
        var stream = await source.Stream.Invoke(new CancellationToken());
        var image = await Task.Run(() =>
        {
            using (var data = NSData.FromStream(stream))
            {
                return UIImage.LoadFromData(data);
            }
        });
        return new Native.Personalization.BandImage(image);
    }

    /// <summary>
    /// アイコン画像の変換
    /// </summary>
    /// <param name="icon">アイコン画像情報</param>
    /// <returns>画像ソース</returns>
    public static StreamImageSource FromNative(Native.Tiles.BandIcon icon)
    {
        return (StreamImageSource)ImageSource.FromStream(icon.UIImage.AsPNG().AsStream);
    }

    /// <summary>
    /// アイコン画像の変換
    /// </summary>
    /// <param name="source">画像ソース</param>
    /// <returns>画像情報</returns>
    public static async Task<Native.Tiles.BandIcon> ToNativeIcon(StreamImageSource source)
    {
        var error = new NSError();
        return Native.Tiles.BandIcon.FromImage(await ToNative(source), out error);
    }
}

といっても元の画像変換処理を流用したのでそれほど変わってません・・・AndroidWindows Phone も含めて

これを利用してアプリタイルのいれものクラスを作ります

/// <summary>
/// iOS 用アプリタイル
/// </summary>
public class NativeBandTile : IBandTile
{
    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="id">ID</param>
    /// <param name="name">名称</param>
    /// <param name="icon">アイコン(46 x 46 px)</param>
    /// <param name="smallIcon">小さいアイコン(24 x 24 px)</param>
    public NativeBandTile(Guid id, string name, Native.Tiles.BandIcon icon, Native.Tiles.BandIcon smallIcon)
    {
        this.tileId = id;
        this.tileIconSource = NativeBandImageConvert.FromNative(icon);
        this.smallIconSource = NativeBandImageConvert.FromNative(smallIcon);

        var error = new NSError();
        this.tile = Native.Tiles.BandTile.Create(new NSUuid(id.ToString("D")), name, icon, smallIcon, out error);
    }

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="tile">アプリタイル</param>
    public NativeBandTile(Native.Tiles.BandTile tile)
    {
        this.tile = tile;

        this.tileId = Guid.Parse(tile.TileId.AsString());

        this.tileIconSource = NativeBandImageConvert.FromNative(tile.TileIcon);

        // Band からタイル情報を取得した場合小さいアイコンがなぜか取れない
        if (tile.SmallIcon != null)
        {
            this.smallIconSource = NativeBandImageConvert.FromNative(tile.SmallIcon);                
        }
    }

    /// <summary>
    /// アプリタイル
    /// </summary>
    private Native.Tiles.BandTile tile = null;

    /// <summary>
    /// アプリタイル
    /// </summary>
    public Native.Tiles.BandTile Tile
    {
        get { return this.tile; }
    }

    /// <summary>
    /// ID
    /// </summary>
    private Guid tileId = Guid.Empty;

    /// <summary>
    /// ID
    /// </summary>
    public Guid TileId
    {
        get { return this.tileId; }
    }

    /// <summary>
    /// 名称
    /// </summary>
    public string Name
    {
        get { return this.tile.Name; }
    }

    /// <summary>
    /// アイコン画像ソース
    /// </summary>
    private StreamImageSource tileIconSource = null;

    /// <summary>
    /// アイコン画像ソース
    /// </summary>
    public StreamImageSource TileIconSource
    {
        get { return this.tileIconSource; }
    }

    /// <summary>
    /// アイコンを設定する
    /// </summary>
    /// <param name="source">アイコン画像ソース(46 x 46 px)</param>
    /// <returns>成功した場合 <code>true</code>、それ以外は <code>false</code></returns>
    public async Task<bool> SetTileIconSource(StreamImageSource source)
    {
        this.tileIconSource = source;

        var error = new NSError();
        if (source == null)
        {
            return this.tile.SetTileIcon(null, out error);
        }

        return this.tile.SetTileIcon(await NativeBandImageConvert.ToNativeIcon(source), out error);
    }

    /// <summary>
    /// 小さいアイコン画像ソース
    /// </summary>
    private StreamImageSource smallIconSource = null;

    /// <summary>
    /// 小さいアイコン画像ソース
    /// </summary>
    public StreamImageSource SmallIconSource
    {
        get { return this.smallIconSource; }
    }

    /// <summary>
    /// 小さいアイコンを設定する
    /// </summary>
    /// <param name="source">小さいアイコン画像ソース(24 x 24 px)</param>
    /// <returns>成功した場合 <code>true</code>、それ以外は <code>false</code></returns>
    public async Task<bool> SetSmallIconSource(StreamImageSource source)
    {
        this.smallIconSource = source;

        var error = new NSError();
        if (source == null)
        {
            this.tile.SetSmallIcon(null, out error);
            return true;
        }

        return this.tile.SetSmallIcon(await NativeBandImageConvert.ToNativeIcon(source), out error);
    }

    /// <summary>
    /// テーマカラー
    /// </summary>
    public BandTheme Theme
    {
        get
        {
            if (this.tile.Theme == null)
            {
                return null;
            }
            return NativeBandThemeConvert.FromNative(this.tile.Theme);
        }

        set
        {
            if (value == null)
            {
                this.tile.Theme = null;
                return;
            }
            this.tile.Theme = NativeBandThemeConvert.ToNative(value);
        }
    }
}

AndroidWindows Phone も同様の作りなんですが、共通プロジェクト側に見せる情報と各プラットフォーム側に見せる情報を両方保持するので、いれものにしては複雑になってしまいました

また、Guid は iOS だと NSUuid、Android だと UUID を利用するのですが、相互変換は下記のように行うようです

// iOS での Guid 変換
NSUuid iosId = new NSUuid(guid.ToString("D"));
guid = Guid.Parse(iosId.AsString());

// Android での Guid 変換
UUID droidId = UUID.FromString(id.ToString("D"));
guid = Guid.Parse(droidId.ToString());

NSUuid → Guid が AsString メソッドでないといけないのがわかりづらいです

さらにこのいれものクラスを利用してアプリタイルの管理クラスを作ります

/// <summary>
/// iOS 用アプリタイル管理クラス
/// </summary>
public class NativeBandTileManager : IBandTileImageManager
{
    /// <summary>
    /// アプリタイル管理クラス
    /// </summary>
    private Native.Tiles.IBandTileManager manager = null;

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="client">接続クライアント</param>
    public NativeBandTileManager(Native.BandClient client)
    {
        this.manager = client.TileManager;
    }

    /// <summary>
    /// 接続クライアントを設定する
    /// </summary>
    /// <param name="client">接続クライアント</param>
    public void SetClient(IBandClient client)
    {
        // Dummy
    }

    /// <summary>
    /// アプリタイルを生成する
    /// </summary>
    /// <param name="id">ID</param>
    /// <param name="name">名称</param>
    /// <param name="icon">アイコンの画像ソース(46 x 46 px)</param>
    /// <param name="smallIcon">小さいアイコンの画像ソース(24 x 24 px)</param>
    /// <param name="theme">テーマカラー</param>
    /// <returns>アプリタイル</returns>
    public async Task<IBandTile> CreateTile(Guid id, string name, StreamImageSource icon, StreamImageSource smallIcon, BandTheme theme = null)
    {
        var nativeIcon = await NativeBandImageConvert.ToNativeIcon(icon);
        var nativeSmallIcon = await NativeBandImageConvert.ToNativeIcon(smallIcon);
        var tile = new NativeBandTile(id, name, nativeIcon, nativeSmallIcon);
        tile.Theme = theme;

        return tile;
    }

    /// <summary>
    /// アプリタイルを追加登録する
    /// </summary>
    /// <remarks>テーマカラーがなぜか上書きされない</remarks>
    /// <param name="tile">アプリタイル</param>
    /// <returns>成功した場合 <code>true</code>、それ以外は <code>false</code></returns>
    public async Task<bool> AddTileAsync(IBandTile tile)
    {
        var native = tile as NativeBandTile;
        if (native == null)
        {
            return false;
        }
        await Native.Tiles.BandTileManagerExtensions.AddTileTaskAsync(this.manager, native.Tile);
        return true;
    }

    /// <summary>
    /// 残りのアプリ枠数を取得する
    /// </summary>
    /// <returns>残りアプリ枠数</returns>
    public async Task<int> GetRemainingTileCapacityAsync()
    {
        return (int)await Native.Tiles.BandTileManagerExtensions.GetRemainingTileCapacityTaskAsync(this.manager);
    }

    /// <summary>
    /// 登録されているアプリタイルを取得する
    /// </summary>
    /// <remarks>小さいアイコンとテーマカラーがなぜか取得できない</remarks>
    /// <returns>アプリタイルのコレクション</returns>
    public async Task<IEnumerable<IBandTile>> GetTilesAsync()
    {
        var tiles = new List<IBandTile>();
        var nativeTiles = await Native.Tiles.BandTileManagerExtensions.GetTilesTaskAsync(this.manager);

        foreach (var tile in nativeTiles)
        {
            tiles.Add(new NativeBandTile(tile));
        }
        return tiles;
    }

    /// <summary>
    /// アプリタイルの登録を削除する
    /// </summary>
    /// <param name="tileId">ID</param>
    /// <returns>成功した場合 <code>true</code>、それ以外は <code>false</code></returns>
    public async Task<bool> RemoveTileAsync(Guid tileId)
    {
        await Native.Tiles.BandTileManagerExtensions.RemoveTileTaskAsync(
            this.manager,
            new NSUuid(tileId.ToString("D")));
        return true;
    }

    /// <summary>
    /// アプリタイルの登録を削除する
    /// </summary>
    /// <param name="tile">アプリタイル</param>
    /// <returns>成功した場合 <code>true</code>、それ以外は <code>false</code></returns>
    public async Task<bool> RemoveTileAsync(IBandTile tile)
    {
        var native = tile as NativeBandTile;
        if (native == null)
        {
            return false;
        }
        await Native.Tiles.BandTileManagerExtensions.RemoveTileTaskAsync(
            this.manager,
            native.Tile);
        return true;
    }
}

変換クラスとアプリタイルのいれものクラスがある程度面倒な部分を吸収するのでこちらはすっきりしました

あとはいつものように ViewModel

/// <summary>
/// アプリタイル情報 ViewModel
/// </summary>
public class TilesViewModel : BindableBase
{
    /// <summary>
    /// 接続クライアント
    /// </summary>
    private IBandClient client = null;

    /// <summary>
    /// アプリタイル管理クラス
    /// </summary>
    private IBandTileImageManager manager = null;

    /// <summary>
    /// アプリタイルID
    /// </summary>
    private static readonly Guid TileId = Guid.Parse("e26c7ebb-5f51-4194-8140-1af8c001a8d7");

    /// <summary>
    /// アプリタイル取得コマンド
    /// </summary>
    public ICommand PullCommand { get; private set; }

    /// <summary>
    /// アプリタイル追加/削除コマンド
    /// </summary>
    public ICommand ToggleCommand { get; private set; }

    /// <summary>
    /// 処理中フラグ
    /// </summary>
    private bool isBusy = false;

    /// <summary>
    /// 処理中フラグ
    /// </summary>
    public bool IsBusy
    {
        get { return this.isBusy; }
        set
        {
            this.SetProperty<bool>(ref this.isBusy, value);
            this.OnPropertyChanged("IsEnableTileManage");
        }
    }

    ~ 中略 ~

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="client">接続クライアント</param>
    [InjectionConstructor]
    public TilesViewModel(IBandClient client, IBandTileImageManager manager)
    {
        this.IsBusy = true;
        this.client = client;
        this.manager = manager;

        this.PullCommand = DelegateCommand.FromAsyncHandler(this.Pull);
        this.ToggleCommand = DelegateCommand.FromAsyncHandler(this.Toggle);

        this.IsBusy = false;
    }

    ~ 中略 ~

    /// <summary>
    /// アプリタイルを追加/削除する
    /// </summary>
    /// <returns>Task</returns>
    private async Task Toggle()
    {
        this.IsBusy = true;

        Exception exception = null;

        try
        {
            var tile = (await this.manager.GetTilesAsync()).FirstOrDefault(t => TileId.Equals(t.TileId));
            if (tile != null)
            {
                await this.manager.RemoveTileAsync(TileId);
                this.ExistsTile = false;
                this.IsBusy = false;
                return;
            }

            var count = await this.manager.GetRemainingTileCapacityAsync();
            if (count < 1)
            {
                await App.Navigation.CurrentPage.DisplayAlert("Warning", "Tile capacity is not enough.", "OK");
                return;
            }
            var created = await this.manager.CreateTile(
                TileId,
                "matatabi Tile",
                (StreamImageSource)ImageSource.FromResource(@"XamarinBandSample.Assets.tile-icon.png"),
                (StreamImageSource)ImageSource.FromResource(@"XamarinBandSample.Assets.small-icon.png"),
                new BandTheme
                {
                    Base = new BandColor(0x00, 0x33, 0x99),
                    HighContrast = new BandColor(0x33, 0x66, 0xcc),
                    Highlight = new BandColor(0x33, 0x66, 0xcc),
                    Lowlight = new BandColor(0x00, 0x33, 0x99),
                    Muted = new BandColor(0x00, 0x00, 0x66),
                    SecondaryText = new BandColor(0x99, 0x99, 0x99),
                });

            await this.manager.AddTileAsync(created);

            this.ExistsTile = true;
            this.Icon = created.TileIconSource;
            this.TileName = created.Name;
            this.BaseColor.Color = string.Format("#FF{0}{1}{2}",
                created.Theme.Base.R.ToString("X2"),
                created.Theme.Base.G.ToString("X2"),
                created.Theme.Base.B.ToString("X2"));
        }
        catch (Exception ex)
        {
            exception = ex;
        }

        if (exception != null)
        {
            await App.Navigation.CurrentPage.DisplayAlert("Error", exception.Message, "OK");
        }

        this.IsBusy = false;
    }
}

アプリタイルの情報を取得して、すでに登録済みだった場合は削除、なかった場合は残りの枠数があれば追加するようにしています

どうやって識別しているのかは謎ですが、スマホアプリが登録したアプリタイルの情報しか取得できないので、他のスマホアプリが登録したタイルはわかりません

最後に XAML 側を改修

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:cv="clr-namespace:XamarinBandSample.Converters;assembly=XamarinBandSample"
             xmlns:c="clr-namespace:XamarinBandSample.Controls;assembly=XamarinBandSample"
             xmlns:t="clr-namespace:XamarinBandSample.Triggers;assembly=XamarinBandSample"
             xmlns:prismmvvm="clr-namespace:Prism.Mvvm;assembly=XamarinBandSample"
             prismmvvm:ViewModelLocator.AutoWireViewModel="true"
             x:Class="XamarinBandSample.Views.TopPage">
  <ContentPage.Resources>
    <ResourceDictionary>
      <cv:NegativeConverter x:Key="NegativeConverter"/>
      <cv:ColorConverter x:Key="ColorConverter"/>
    </ResourceDictionary>
  </ContentPage.Resources>
  <ContentPage.Padding>
    <OnPlatform x:TypeArguments="Thickness"
                iOS="0,20,0,0"/>
  </ContentPage.Padding>
  <ContentPage.ToolbarItems>
    <ToolbarItem Text="Basics"
                 Command="{Binding SelectBasicsCommand}">
    </ToolbarItem>
    <ToolbarItem Text="Sensors"
                 Command="{Binding SelectSensorsCommand}">
    </ToolbarItem>
    <ToolbarItem Text="Personalize"
                 Command="{Binding SelectPersonalizeCommand}">
    </ToolbarItem>
    <ToolbarItem Text="Tiles"
                 Command="{Binding SelectTilesCommand}">
    </ToolbarItem>
  </ContentPage.ToolbarItems>

  <Grid>

    ~ 中略 ~

    <!-- Tiles Info Pain-->
    <ScrollView IsVisible="{Binding ShowTiles}"
                Orientation="Vertical">
      <Grid BindingContext="{Binding Tiles}"
            Padding="10"
            RowSpacing="10"
            VerticalOptions="StartAndExpand">
        <Grid.RowDefinitions>
          <RowDefinition Height="102"/>
          <RowDefinition Height="*"/>
        </Grid.RowDefinitions>

        <Grid Grid.Row="0"
              ColumnSpacing="10"
              IsVisible="{Binding ExistsTile}">
          <Grid.ColumnDefinitions>
            <ColumnDefinition Width="102"/>
            <ColumnDefinition Width="*"/>
          </Grid.ColumnDefinitions>
          <BoxView Grid.Column="0"
                   WidthRequest="102"
                   HeightRequest="102"
                   BackgroundColor="{Binding BaseColor.Color, Converter={StaticResource ColorConverter}}"/>
          <Image Grid.Column="0"
                 Source="{Binding Icon}"
                 HorizontalOptions="Center"
                 VerticalOptions="Center"
                 WidthRequest="46"
                 HeightRequest="46"/>
          <Label Grid.Column="1"
                 Text="{Binding TileName}"
                 HorizontalOptions="StartAndExpand"
                 VerticalOptions="CenterAndExpand"
                 FontSize="Medium"/>
        </Grid>

        <Label Grid.Row="0"
               IsVisible="{Binding ExistsTile, Converter={StaticResource NegativeConverter}}"
               Text="No tiles registered"
               HeightRequest="102"
               HorizontalOptions="Center"
               VerticalOptions="Center"
               FontSize="Medium"/>

        <StackLayout Grid.Row="1"
                     Orientation="Horizontal"
                     Spacing="10"
                     HorizontalOptions="End">
          <Button Text="Pull"
                  IsEnabled="{Binding IsBusy, Converter={StaticResource NegativeConverter}}"
                  Command="{Binding PullCommand}"
                  VerticalOptions="Center"
                  FontSize="Medium"/>
          <Button Text="Add Tile"
                  IsVisible="{Binding ExistsTile, Converter={StaticResource NegativeConverter}}"
                  IsEnabled="{Binding IsBusy, Converter={StaticResource NegativeConverter}}"
                  Command="{Binding ToggleCommand}"
                  VerticalOptions="Center"
                  FontSize="Medium"/>
          <Button Text="Remove Tile"
                  IsVisible="{Binding ExistsTile}"
                  IsEnabled="{Binding IsEnableTileManage}"
                  Command="{Binding ToggleCommand}"
                  VerticalOptions="Center"
                  FontSize="Medium"/>
        </StackLayout>
      </Grid>

    </ScrollView>

  </Grid>

</ContentPage>

追加アプリタイルの表示領域と追加/削除ボタンなどをつけました

これで実行すると・・・

f:id:matatabi_ux:20150503183833p:plain f:id:matatabi_ux:20150503183844p:plain f:id:matatabi_ux:20150503183857p:plain

追加する際に SDK 側で確認ダイアログが表示され、「Accept」をタップすると追加が実行されました

f:id:matatabi_ux:20150503183948j:plain

タイルは見ての通り追加されたのですが、テーマカラーで青系統の色を設定したのに反映されない;

SDK の説明には開発者がテーマを上書きできると書いてあるので、Binding か SDK の不具合でしょうか・・・よくわかりません;