kamulog

xamarin.formsのネタなど

Xamarin.FormsのPan/PinchGestureRecognizerを使ってドラッグアンドドロップと拡大縮小を実装するサンプル

この記事は[学生さん・初心者さん大歓迎!]Xamarin Advent Calendar 2016 - Qiitaの17日目の記事です。

Xamarin.Formsでいつの間にかPanGestureRecognizerとPinchGestureRecognizerが増えてたのであれこれと試してみました。

はじめに

今作成しているアプリで画像をトリミングする機能が必要になってきたので Lineとかにある

f:id:kamusoft:20161230234316j:plain

こういうUIをパンとピンチを使ってXamarin.Formsの機能だけで作成してみました。

必要な機能は

  • 画像を半透明レイヤーでクリッピングマスク
  • PanGestureでトリミング範囲の移動
  • PinchGestureでトリミング範囲の変更

といった感じです。

今回はトリミングの処理はありません。

完成品

github.com

f:id:kamusoft:20161231004621g:plain

PanやPinchの前にクリッピングマスク

画像に半透明のレイヤーをかぶせてその一部をくり抜くクリッピングマスク的な表現ですが、まずここが地味に悩みました。

いろいろ試行錯誤した結果、AbsoluteLayoutに背景用のImageとAbsoluteLayoutを置いて、そのIsClippedToBoundsをtrueにしてさらにその子にImageを置いてなんとかそれっぽくなりました。意味不明ですよね…。とりあえずXamlはこんな感じです。

<StackLayout Spacing="10" Margin="10" IsClippedToBounds="true">
    <AbsoluteLayout VerticalOptions="FillAndExpand" BackgroundColor="Transparent" x:Name="parentLayout" >
        <Image Source="{Binding BackImage}" AbsoluteLayout.LayoutBounds="0,0,300,300" Opacity="0.2" x:Name="backImage" />
        <AbsoluteLayout AbsoluteLayout.LayoutBounds="50,50,50,50"  VerticalOptions="Start" HorizontalOptions="Center" IsClippedToBounds="true" x:Name="clipRealm">
            <Image Source="{Binding ImageSrc}" AbsoluteLayout.LayoutBounds="-50,-50,300,300"  x:Name="foreImage" />
        </AbsoluteLayout>
    </AbsoluteLayout>
</StackLayout>

ここの内側のAbsoluteLayoutとその子のImageの部分がクリッピングマスクの処理です。 子のImageのサイズを意図的に親より大きくしてさらに座標をマイナスにずらしています。 AbsoluteLayoutのIsClippedToBoundsをtrueにしていると子がはみ出した範囲をカットしてくれるというもので、これと親より大きいImageとマイナス座標を組み合わせることで画像の指定の範囲だけをくり抜くような感じになります。

今フォトショ的なツールが無い環境なんでペイントのいい加減な画像で説明しますとこんな感じです。 タマリンの画像が子のImageで赤枠が親のAbsoluteLayoutのイメージです。座標はAbsoluteLayoutの方は親のAbsoluteLayoutからの起点で、Imageは赤枠のAbsoluteLayoutの起点です。

f:id:kamusoft:20161231002218j:plain

この状態でIsClippedToBoundsをtrueにすると

f:id:kamusoft:20161231002226j:plain

こんな感じになるわけです。

ただこれだとくり抜かれたところしか表示できないんで、もう一枚同じ画像を同じ大きさで用意して外側のAbsoluteLayoutの子に配置してこんな感じに。

f:id:kamusoft:20161231003747j:plain

実際には上のXamlだけじゃなくてコードビハインドでSizeChangeイベントで画像のサイズを揃えたりしてます。

Panによるドラッグ&ドロップでの移動

最初はPanGestureRecognizerのPanUpdatedイベントを購読して普通に実装していったんですが、iOSでは問題無くてもAndroidで超カクカク問題が発生して、それに対応するために非同期でLayoutToを実行するようにしたんですが、そうするとawaitしてる間に次のイベントがやってきて順番がめちゃくちゃになりカオスに…という状況だったので最終的にReactiveExtensionsを使った方法となりました。

void SetPanObservable()
{
    var sampleSeconds = 150; //サンプリングの間隔
    var moveSeconds = 50; //移動アニメーションの時間

    var panObservable = PanUpdateAsObservable();

    var started = panObservable.Where(x => x.StatusType == GestureStatus.Started);
    var completed = panObservable.Where(x => x.StatusType == GestureStatus.Completed);

    started.Subscribe(x => Debug.WriteLine("Pan Started"));
    completed.Subscribe(x => Debug.WriteLine("Pan Completed"));

    var running = panObservable
        .SkipWhile(x => x.StatusType != GestureStatus.Running)  //Running以外はスキップ
        .TakeWhile(x => x.StatusType == GestureStatus.Running); //Runningの間は流し続ける

    //Androidの場合一定時間でイベントをまとめて処理(カクカク対策)
    if (Device.OS == TargetPlatform.Android) {
        running = running
            .Sample(TimeSpan.FromMilliseconds(sampleSeconds))   //一定時間ごとにサンプリング
            .StartWith(new PanUpdatedEventArgs(GestureStatus.Running, 0, 0, 0)); //最初だけ起点用に発行
    }

    //前の値が必要なので一つずらしたものとZipで合成
    var drag = running.Zip(
                running.Skip(1),
                (p, n) => new { PreTotal = new Point(p.TotalX, p.TotalY), Total = new Point(n.TotalX, n.TotalY) }
            ).Repeat(); //繰り返し


    var dragSub = drag.Subscribe(async p => {

        //移動距離の計算
        var distance = Device.OnPlatform(
         iOS: new Point(p.Total.X - p.PreTotal.X, p.Total.Y - p.PreTotal.Y),  //iOSは移動いてもTotalはリセットされないので差分を使用する
         Android: new Point(p.Total.X, p.Total.Y),    // Androidは移動後にTotalがリセットされるのでそのまま使う
         WinPhone: new Point());

        //現在の位置を取得
        var rectClip = AbsoluteLayout.GetLayoutBounds(clipRealm);
        var rectImage = AbsoluteLayout.GetLayoutBounds(foreImage);

        //座標計算
        rectClip.X = Math.Max(0, Math.Min(backImage.Width - rectClip.Width, rectClip.X + distance.X));
        rectClip.Y = Math.Max(0, Math.Min(backImage.Height - rectClip.Height, rectClip.Y + distance.Y));

        rectImage.X = Math.Max(rectClip.Width - backImage.Width, Math.Min(0, rectImage.X - distance.X));
        rectImage.Y = Math.Max(rectClip.Height - backImage.Height, Math.Min(0, rectImage.Y - distance.Y));

        //Androidの場合はLayoutToで移動しないとカクカクMAXで使い物にならない
        //iOSはそのままでぬるぬる動くのでスキップ(LayoutToをすると逆にカクカクになる)
        if (Device.OS == TargetPlatform.Android) {
            await Task.WhenAll(
                clipRealm.LayoutTo(rectClip, (uint)moveSeconds, Xamarin.Forms.Easing.Linear),
                foreImage.LayoutTo(rectImage, (uint)moveSeconds, Xamarin.Forms.Easing.Linear)
                );
        }

        //AbsoluteLayoutのパラメータに反映
        AbsoluteLayout.SetLayoutBounds(clipRealm, rectClip);
        AbsoluteLayout.SetLayoutBounds(foreImage, rectImage);
    });

    _disposable.Add(dragSub);
}


IObservable<PanUpdatedEventArgs> PanUpdateAsObservable()
{
    return Observable.FromEvent<EventHandler<PanUpdatedEventArgs>, PanUpdatedEventArgs>(
        handler => (sender, e) => handler(e),
        handler => _pan.PanUpdated += handler,
        handler => _pan.PanUpdated -= handler
    );
}

Rx力が未熟なのでいろんな解説サイトを読みながら書きました。 iOSAndroidでの実際の動作があちらをたててはこちらが立たずのため苦労しました。

Androidの場合はViewを動的に動かすにはLayoutToが必須なのかも知れません。あと特筆すべきはイベント引数のTotalXやTotalYですが、iOSの方は要素を動かしても何も問題はないんですが、Androidは要素を動かした時点でTotalXとTotalYが0に戻る挙動となっていました。なのでOnPlatformでiOSの方は前のTotalと今のTotalからの差分で移動距離を出しているところをAndroidではTotalだけで移動距離とさせてます。

座標計算はLayoutとImageを逆方向に同期してずらさらないといけないのでその処理やら端に達したときの処理をがんばってますw

Pinchによる拡大縮小

ピンチの仕様としては

  • 辺の近くでの平行のピンチは該当する辺のみをリサイズ
  • それ以外はアスペクト固定でリサイズ

としました。 本当は斜めピンチでアスペ固定、水平ピンチで横、垂直ピンチで縦という動きにしたかったんですが、ピンチの方向をとる手段がなかったので諦めました。

イベント引数のScaleOriginはピンチの中心座標を相対座標0~1で表したものが入ってきますので、これを利用してどの辺りでピンチが始まったかということを判定することができます。

底辺付近でのピンチはXが0.5前後、Yが0.9前後となり、右端縦のピンチではXが0.9前後、Yが0.5前後となるといった具合です。

こちらはあまりトラブルはなく非同期にすることもなくできたので、通常のイベント処理でもできたと思うのですがPanに合わせる形でこちらもRxを使用したものにしました。

void SetPinchObservable()
{
    var minSize = 50;   //最小の大きさ
    var sideHitSize = 0.3; //ピンチの位置が相対的にどの辺で端と判断するか

    var pinchOrientation = ScrollOrientation.Both; //ピンチの方向

    var pinchObservable = PinchUpdateAsObervable();

    var started = pinchObservable.Where(x => x.Status == GestureStatus.Started);
    pinchObservable.Where(x => x.Status == GestureStatus.Completed).Subscribe(x => Debug.WriteLine("Pinch Completed"));;

    var startedSub = started.Subscribe(e => {
        Debug.WriteLine("Pinch Started");
        //拡大・縮小の方向の判定
        if ((e.ScaleOrigin.X <= sideHitSize || e.ScaleOrigin.X >= 1 - sideHitSize) && e.ScaleOrigin.Y <= 0.6 && e.ScaleOrigin.Y >= 0.4) {
            pinchOrientation = ScrollOrientation.Vertical;
        }
        else if ((e.ScaleOrigin.Y <= sideHitSize || e.ScaleOrigin.Y >= 1 - sideHitSize) && e.ScaleOrigin.X <= 0.6 && e.ScaleOrigin.X >= 0.4) {
            pinchOrientation = ScrollOrientation.Horizontal;
        }
        else {
            pinchOrientation = ScrollOrientation.Both;
        }
    });

    _disposable.Add(startedSub);

    var running = pinchObservable
        .SkipWhile(x => x.Status != GestureStatus.Running) //Running以外はスキップ
        .TakeWhile(x => x.Status == GestureStatus.Running) //Runningが続く間は流し続ける
        .Repeat(); //繰り返す

    var dragSub = running.Subscribe(e => {

        //現在の状態を取得
        var rectClip = AbsoluteLayout.GetLayoutBounds(clipRealm);
        var rectImage = AbsoluteLayout.GetLayoutBounds(foreImage);
        //拡大縮小後のRectangle
        var scaledRect = rectClip;

        //垂直の座標計算
        if (pinchOrientation == ScrollOrientation.Vertical || pinchOrientation == ScrollOrientation.Both) {

            scaledRect.Height = Math.Min(Math.Max(rectClip.Height * e.Scale, minSize), backImage.Height);
            scaledRect.Y = Math.Min(Math.Max(rectClip.Center.Y - (scaledRect.Height / 2), 0), backImage.Height - scaledRect.Height);
            rectImage.Y = scaledRect.Y * -1;

        }
        //水平の座標計算
        if (pinchOrientation == ScrollOrientation.Horizontal || pinchOrientation == ScrollOrientation.Both) {

            scaledRect.Width = Math.Min(Math.Max(rectClip.Width * e.Scale, minSize), backImage.Width);
            scaledRect.X = Math.Min(Math.Max(rectClip.Center.X - (scaledRect.Width / 2), 0), backImage.Width - scaledRect.Width);
            rectImage.X = scaledRect.X * -1;
        }

        //レイアウトに反映
        AbsoluteLayout.SetLayoutBounds(clipRealm, scaledRect);
        AbsoluteLayout.SetLayoutBounds(foreImage, rectImage);

    });

    _disposable.Add(dragSub);
}

IObservable<PinchGestureUpdatedEventArgs> PinchUpdateAsObervable()
{
    return Observable.FromEvent<EventHandler<PinchGestureUpdatedEventArgs>, PinchGestureUpdatedEventArgs>(
        handler => (sender, e) => handler(e),
        handler => _pinch.PinchUpdated += handler,
        handler => _pinch.PinchUpdated -= handler
    );
}

Panよりかはかなり素直でした。特にイベント引数のScaleは本当に良い感じの割合が返ってきて特に調整する必要はありませんでした。

課題

Panは課題だらけというかAndroidではぬるぬる動かすのはNative処理しないと無理っぽいです。Rx力が高ければなんとかできるんでしょうかね。 なんで、今回みたいにぐりぐりドラッグで引きずり回すのは向いてないので、ちょっと隣に移動とか、入れ替えとかその辺なら問題なく使えるんではないかと思います。

それからPinchとPanのイベントがたまに混ざってしまっておかしな動きになってしまうことがあります。これは多分うまくRxを使えてないからだと思います。

あとgif画像にも出てますがピンチで最大まで拡大したあとなんか画像がブレますね…。 これはクリッピングマスクの辺りの問題な気がしますが、今回はここまでで力尽きました。

まとめ

  • くり抜きはめんどくさいがIsClippedToBoundsをtrueにすればできないことはない
  • PanGestureRecognizerはプラットフォームの差異が吸収されきってないような気がするのでOnPlatform祭り
  • PanのTotalX、TotalYには気を付けよう
  • Panはちょっとずらすだけくらいの要件にとどめておいた方が良いかも
  • PinchGestureRecognizerはとても良い感じにスケール情報が取得できて素敵!
  • Reactive Extensions 素敵すぎる

参考

今回使用したエンペラータマリンの画像

著作者:Steve Wilson

free-photos.gatag.net