Xamarin.Forms でドラッグを実装しよう(のうりん編)
http://www.moonmile.net/blog/archives/7653
の続きで「2.Xamarin.Android 上で Touch イベントを使って実装」のところをメモ書きします。一応、Xamarin.Forms on Android までは動いたので、あとは微調整(ドラッグしているときの位置に誤差が含まれているので)と iOS 対応版を作るところですね。
どうも Xamarin.Forms の場合はリスナーを使ってあれこれやるっぽいのですが、C# の場合はリスナーを使うよりもイベントやデリゲートを使う方が楽なので、そっちのほうに流れます。さらにいうと、Xamarin.Forms でネイティブのイベントハンドラを拾う(iOS/Android編) でネイティブのコントロールに直接バインドしてしまって、レンダラー無しのパターンでもよいかもしれません。こっちのほうは先行き実装ということで。
Xamarin.Android を Touch イベントでドラッグ
Xamarin.Forms を直接弄る前に、Xamarin.Android を使って C# でのドラッグを確認しておきます。コードとしては Java から C# へコンバートすれば良いので比較的簡単ではあるのですが、Xamarin.Forms で動かすことを考えて、Android/iOS/WinPhone のイベントを共通化するために、疑似的に ManipulationDelta を使って動かせるようにします。ストアアプリ/UWP の場合には、コントロールのドラッグイベントを ManipulationDelta を使って移動差分を利用するとコントロールの位置指定簡単になるのです。
画面的にこんな風になります。
- 初期化ボタンで、最初の位置にジャンプさせる。
- 上下左右ボタンで、20ドットずつ移動
- 黄色のコントロールをドラッグする
初期化と上下左右ボタンは、Layout の位置チェックに使っています。最終的には移動先の座標を保存しておいて復元することも視野にいれるので、ドラッグ先の位置や整列の方法も調べておく必要があります。
TextViewで GetX,Y と RawX,Y を表示させていますが GetX,Y は正確な値ではありません。
ちなみに
- GetX(), GetY() がコントロールに対する相対位置
- RawX, RawY がタップの絶対位置(画面の左上が基準点)
という違いがあります。
ややこしいですが、こんな図の関係になります。box がドラッグするコントロールで、layout が box を載せているコンテナコントロールです。ここでは、RelativeLayout を使います。
タップした位置は RawX,Y で取れますが、ドラッグするコントロールは box.Left, box.Top のように layout の相対位置に直さねばいけません。また、タップした相対位置は、Touch イベントでとれるのですが、GetX,GetY はタップ対象コントロールの相対位置になるので、この場合は、box に対しての相対位置になります。
相対位置で計算するがベストなのですが、タップしたときの相対位置が、ドラッグさせて移動させてしまうコントロールそのものに対しての相対位置なので話がややこしくなります。このあたり本来ならば、コンテナのほううで Touch イベントを取るほうがよいのですが、Xamarin.Forms のレンダラーの制限のために、この方法になっています。まあ、ちょっとこのあたりはあとで調節ですね。
画面の axml
TextView コントロールをドラックさせるため、RelativeLayout の中に張り付けます。
コンテナに対しての相対位置は layout_marginTop と layout_marginLeft で指定しておきます。
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent"> <Button android:text="初期化" android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/button1" /> <LinearLayout android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/linearLayout1"> <Button android:text="上" android:layout_width="wrap_content" android:layout_height="match_parent" android:id="@+id/buttonUp" /> <Button android:text="下" android:layout_width="wrap_content" android:layout_height="match_parent" android:id="@+id/buttonDown" /> <Button android:text="左" android:layout_width="wrap_content" android:layout_height="match_parent" android:id="@+id/buttonLeft" /> <Button android:text="右" android:layout_width="wrap_content" android:layout_height="match_parent" android:id="@+id/buttonRight" /> </LinearLayout> <TextView android:text="GetX,Y: 000,000" android:textAppearance="?android:attr/textAppearanceMedium" android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/textView1" /> <TextView android:text="RawX,Y: 000,000" android:textAppearance="?android:attr/textAppearanceMedium" android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/textView2" /> <RelativeLayout android:minWidth="25px" android:minHeight="25px" android:layout_width="match_parent" android:layout_height="match_parent" android:id="@+id/relativeLayout1"> <TextView android:text="Box" android:textAppearance="?android:attr/textAppearanceLarge" android:layout_width="60dp" android:layout_height="60dp" android:id="@+id/box1" android:layout_marginTop="100dp" android:layout_marginLeft="100dp" android:background="#ffbb33" /> </RelativeLayout> </LinearLayout>
OnCreate で初期化する
OnCreate メソッドで、画面が作成されたときにボタンのイベントを追加しておきます。
RelativeLayout 内での座標の指定は、RelativeLayout.LayoutParams と SetMargins メソッドを使います。このあたり使い方がややこしいですよね。RelativeLayout.LayoutParams の部分は、コンテナで利用する layout によって変える必要があります。
protected override void OnCreate(Bundle bundle) { base.OnCreate(bundle); // Set our view from the "main" layout resource SetContentView(Resource.Layout.Main); // Get our button from the layout resource, // and attach an event to it var text1 = this.FindViewById<TextView>(Resource.Id.textView1); var text2 = this.FindViewById<TextView>(Resource.Id.textView2); var box = this.FindViewById<View>(Resource.Id.box1); var btn1 = this.FindViewById<Button>(Resource.Id.button1); // 位置を初期化 btn1.Click += (s, e) => { setPos(50, 50); dispPos(); }; // 位置移動 this.FindViewById<Button>(Resource.Id.buttonLeft).Click += delegate { setPos(box.Left-20, box.Top); dispPos(); }; this.FindViewById<Button>(Resource.Id.buttonRight).Click += delegate { setPos(box.Left + 20, box.Top); dispPos(); }; this.FindViewById<Button>(Resource.Id.buttonUp).Click += delegate { setPos(box.Left, box.Top - 20); dispPos(); }; this.FindViewById<Button>(Resource.Id.buttonDown).Click += delegate { setPos(box.Left, box.Top + 20); dispPos(); }; box.Touch += Box_Touch; // OnManipulationDelta を設定 this.ManipulationDelta += OnManipulationDelta; } // 位置指定 void setPos( int x, int y ) { var box = this.FindViewById<View>(Resource.Id.box1); // var lp = new RelativeLayout.LayoutParams(box.Width, box.Height); if ( _box_w == 0 || _box_h == 0 ) { _box_w = box.Width; _box_h = box.Height; } var lp = new RelativeLayout.LayoutParams(_box_w, _box_h); lp.SetMargins(x,y, 0, 0); box.LayoutParameters = lp; }
ちなみに、box.Top, box.Left は get/set の両方があるので設定も可能なのですが、これに値を入れると Width/Height も自動的に変わってしまいます。Width/Height は get しかないので謎な仕様です。
タップは、Touch イベントに結び付けます。ManipulationDelta のほうはテスト用に作ったものです。
Touch イベント
ドラッグ時のタップイベントは、MotionEventActions.Down/Move/Up の処理をします。
box の新しい位置を決定するときに、直接、絶対座標の GetX,GetY を使って指定すれば良いのですが、コンテナやドラッグ対象のコントロールの位置指定が相対座標のためうまくいきません。仕方がないので、前回の絶対位置を保持しておいて差分を使ってコントロールの位置決めをします。
private void Box_Touch(object sender, View.TouchEventArgs e) { var box = sender as View; switch( e.Event.Action ) { case MotionEventActions.Down: // 初期の相対値を保存 _gx = e.Event.GetX(); _gy = e.Event.GetY(); _box_w = box.Width; _box_h = box.Height; break; case MotionEventActions.Move: // 移動距離を計算 float dx = e.Event.RawX - _ox; float dy = e.Event.RawY - _oy; // 移動 // TODO: 誤差で少しずれるが実用上問題ない // setPos((int)box.Left + (int)dx, (int)box.Top + (int)dy); // OnManipulationDelta(sender, new ManipulationDeltaRoutedEventArgs(sender, (int)dx, (int)dy)); // イベント呼び出し if ( ManipulationDelta != null ) { ManipulationDelta(sender, new ManipulationDeltaRoutedEventArgs(sender, (int)dx, (int)dy)); } break; case MotionEventActions.Up: break; } // 現在の絶対位置を保存 _ox = e.Event.RawX; _oy = e.Event.RawY; }
差分を使ったときの欠点は、誤差が蓄積されてしまうことです。絶対座標の GetX,GetY は float 型なのですが、box の Left/Top は int 型です。このあたりがずれているために、前回の位置(int型)+ 変化量(float型)が、位置(int型)に丸められてしまって、だんだんと誤差が溜まってしまいます。タップした位置に合わせるように誤差を補正する方法が必要でしょう。これはあとで考えます。
疑似的な ManipulationDelta
移動量を差分で計算する場合は、誤差が溜まるという欠点はあるのですが、設定する座標の計算が楽になります。単純に前回の位置に移動量を足せば、今回の位置になるからです。これが、Windowsストアアプリ/UWP で使われている方法で、ManipulationDelta を使って加算していきます。
どうせ、Xamarin.Forms で 3つのプラットフォームを共通化しなければいけない(WPFも合わせると4つのプラットフォームになる)ので、疑似的に ManipulationDelta を使えるようにしてしまいます。
名前だけ合わせるために、無理矢理 ManipulationDeltaRoutedEventArgs クラスを作り、OnManipulationDelta メソッドが扱えるようにします。どうせ C# なのだから、コーディングスタイルも UWP に合わせてしまうほうがいいですよね。
public class ManipulationDeltaRoutedEventArgs { public ManipulationDeltaRoutedEventArgs( object source, int deltaX, int deltaY ) { this.OriginalSource = source; this.Delta = new Delta_() { Translation = new Delta_.Translation_() { X = deltaX, Y = deltaY } }; } public Delta_ Delta { get; set; } public object OriginalSource { get; set; } public class Delta_ { public Translation_ Translation { get; set; } public class Translation_ { public int X { get; set; } public int Y { get; set; } } } } public virtual void OnManipulationDelta( object sender, ManipulationDeltaRoutedEventArgs e ) { var el = e.OriginalSource as View; int x = el.Left + e.Delta.Translation.X; int y = el.Top + e.Delta.Translation.Y; // left, top は同時に設定する必要あり // RelativeLayoutHelper.SetLeft(el, x); // RelativeLayoutHelper.SetTop(el, y); RelativeLayoutHelper.SetXY(el, x, y); } void setPos(int x, int y) { var box = this.FindViewById<View>(Resource.Id.box1); // var lp = new RelativeLayout.LayoutParams(box.Width, box.Height); if (_box_w == 0 || _box_h == 0) { _box_w = box.Width; _box_h = box.Height; } var lp = new RelativeLayout.LayoutParams(_box_w, _box_h); lp.SetMargins(x, y, 0, 0); box.LayoutParameters = lp; } } class RelativeLayoutHelper { public static void SetLeft( View el, int left) { var lp = new RelativeLayout.LayoutParams(el.Width, el.Height); lp.SetMargins(left, el.Top, 0, 0); el.LayoutParameters = lp; } public static void SetTop(View el, int top) { var lp = new RelativeLayout.LayoutParams(el.Width, el.Height); lp.SetMargins(el.Left, top, 0, 0); el.LayoutParameters = lp; } public static void SetXY(View el, int left, int top) { var lp = new RelativeLayout.LayoutParams(el.Width, el.Height); lp.SetMargins(left, top, 0, 0); el.LayoutParameters = lp; } }
ManipulationDelta 方式を使うときには、キャンバス(Canvas)を使って、Canvas.SetLeft と Canvas.SetTop が頻発するので同じようなものを作っておきます。RelativeLayoutHelper.SetXY とすることで、座標を指定できます。試しに SetLeft と SetTop を作ってみたのですが片方ずつ設定することができませんでした。たぶん、設定するタイミングと View で反映されて再計算されるタイミングが違うからですね。仕方がないので、両方設定するときは SetXY を使います。
サンプルコード
そんな訳で、Xamarin.Android を使って TextView をドラッグさせることができました。更に、疑似的に ManipulationDelta を使うようにしたので Xamarin.Forms で共通化できそうです。
https://github.com/moonmile/BoxDrag/tree/master/BoxDrag.Android
では、引き続き Xamarin.Forms のほうも。