AndroidでDatePickerDialogのまがい物を作る

2日かけて、AndroidのDatePickerDialogのまがい物を作った。名前被らないように、DateSelectDialogって名前にした。

何で作ろうと思ったかというと、標準のDatePickerDialogでは「年だけ選択させる」とか「年と月だけ選択させる」っていうのができないから。それを実現しようと思うと、

yukiasu.blogspot.jp

この方のような方法をとることになる。昔のUIだったらこれでいけたんだけど、最近のDatePickerDialogは下の画像のようにすごい複雑化してるので、ただ単に「日付選択のViewだけ非表示にする」っていうのではうまくいかなさそう。

f:id:YaaMaa:20170220120210p:plain

今では非推奨となってしまったHOLOテーマを指定する手もあるけど、マテリアルデザインが好きなので戻りたくない…。そこで、もう自分でまがい物を作ればいいんちゃうかなって思った。

現在の年選択ダイアログが

f:id:YaaMaa:20170220120702p:plain

こんなん。この上半分を消して、年選択の右側に月選択と日選択のスピナーを表示させたらいいんかな?

年選択はListViewで実装されてるっぽいので、それに倣ってやってみる。

完成図は下のような感じ。

f:id:YaaMaa:20170220121501p:plain

f:id:YaaMaa:20170221015054p:plain


ダイアログの中身

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingTop="@dimen/dialog_padding_top"
    android:paddingBottom="@dimen/dialog_padding_bottom"
    android:paddingStart="@dimen/dialog_horizontal_padding"
    android:paddingEnd="@dimen/dialog_horizontal_padding"
    android:baselineAligned="false">

    <ListView
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:divider="@null"
        android:id="@+id/year"/>

    <ListView
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:divider="@null"
        android:id="@+id/month"/>

    <ListView
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:divider="@null"
        android:id="@+id/date"/>

</LinearLayout>

weightを1にして、均等に並べる。

ListViewの中身

<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="@dimen/date_select_dialog_item_height">

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/ic_dateselect_circle"
        android:layout_centerInParent="true"
        android:tint="@color/colorAccent"
        android:alpha=".38"
        android:id="@+id/image"/>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:gravity="center"
        android:textSize="13sp"
        android:id="@+id/text"/>

</RelativeLayout>

ImageViewは背景の丸で、TextViewは「2016」とかの文字。

Dialogのコード

このダイアログを実際に使うときには

DateSelectDialog dialog = new DateSelectDialog();
Bundle args = new Bundle();
args.putInt("mode", Calendar.DATE);
args.putInt("default", CalendarManager.toEpochtime(date, Calendar.DATE));
dialog.setArguments(args);
dialog.show(getActivity().getSupportFragmentManager(), "dialog");

このように、"mode"にCalendar.DATE or Calendar.MONTH or Calendar.YEARを指定することでどこまで選択させるかを決め、"default"で初期値を入れることにする。なので、そういう情報はgetArguments()から取ってくる。

ダイアログクラスのonCreateDialogは下のようになった。

@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
  inflater = LayoutInflater.from(getContext());
  final View contentView = inflater.inflate(R.layout.date_select_dialog, null);

  mode = getArguments().getInt("mode");
  selected = CalendarManager.fromEpochtime(getArguments().getInt("default"), mode);

  ListView yearList = (ListView) contentView.findViewById(R.id.year);
  // monthとdateも同様

  // ダイアログ作る
  Dialog dialog = new AlertDialog.Builder(getContext())
    .setView(contentView)
    .setPositiveButton("OK", new DialogInterface.OnClickListener() {
      @Override
      public void onClick(DialogInterface dialog, int which) {
        // あとで書く
      }
    })
    .setNegativeButton("Cancel", null)
    .create();

  // modeに応じてListViewを消す
  if (mode == Calendar.MONTH || mode == Calendar.YEAR) {
      dateList.setVisibility(View.GONE);
      if (mode == Calendar.YEAR) {
          monthList.setVisibility(View.GONE);
      }
  }

  // リストの中身を作成(数を+2してるのは、端っこに空白を作るため)
  List<Integer> yearObjects = new ArrayList<>();
  for (int i = 0; i < YEAR_LAST-YEAR_FIRST + 1 + 2; i++) {
      yearObjects.add(i);
  }
  List<Integer> monthObjects = new ArrayList<>();
  for (int i = 0; i < MONTH_COUNT + 2; i++) {
      monthObjects.add(i);
  }
  List<Integer> dateObjects = new ArrayList<>();
  for (int i = 0; i < CalendarManager.getEndOfMonth(selected) + 2; i++) {
      dateObjects.add(i);
  }

  // アダプタをセット
  yearList.setAdapter(new DateSelectDialogListAdapter(getContext(), 0, yearObjects, Calendar.YEAR));
  // monthとdateも同様

  // 端をフェードさせる
  yearList.setVerticalFadingEdgeEnabled(true);
  // monthとdateも同様

  updateTitle(dialog); // 現在選択されてる日時をタイトルにセットする
  return dialog;
}

Dialogの中身のListViewのアダプタ

まず、日付を変換する必要があったので、いろいろメソッドを用意した。この辺で使う数字には3タイプある。

  • リストでのポジション(0インデックス)

  • 普通に1から始まる(年は1970からだけど)表示用の数字

  • カレンダーにセットするとき用の数字(日付・年は表示用のままでいいのに、月だけは0インデックス)

それらを互いに変換しようと思うと、下のようになった。もうちょっと効率のいい方法あったかも…。

// positionから表示用の数字へ
static int pos2Displayed(int mode, int position) {
  switch (mode) {
    case Calendar.DATE:
      return position;
    case Calendar.MONTH:
      return position;
    case Calendar.YEAR:
    default:
      return position + YEAR_FIRST - 1;
  }
}

// positionからCalendar用の数字へ
static int pos2Calendar(int mode, int position) {
  switch (mode) {
    case Calendar.DATE:
      return position;
    case Calendar.MONTH:
      return position - 1;
    case Calendar.YEAR:
    default:
      return position + YEAR_FIRST - 1;
  }
}

// Calendar用の数字からpositionへ
static int calendar2Pos(int mode, Calendar calendar) {
  switch (mode) {
    case Calendar.DATE:
      return calendar.get(mode);
    case Calendar.MONTH:
      return calendar.get(mode) + 1;
    case Calendar.YEAR:
    default:
      return calendar.get(mode) - YEAR_FIRST + 1;
  }
}

ここからが一番ややこしかった。

特に、リストの一番上と一番下に空白を追加するところが厄介だった。

空白を入れないと、

f:id:YaaMaa:20170220131718p:plain

こんな感じで、フォーカスされてる日付を真ん中に持ってこようとしても限界がある。だから、空白を加えて下のようにする。

f:id:YaaMaa:20170220131805p:plain

その空白の高さ(ここではoffsetって名前にしてる)は「(ListView全体の高さ - 一行の高さ) / 2」なんだけど、ListView全体の高さっていうのがgetViewに来てからでないと求まらんかった。DialogFragmentの方のonCreateDialogとかonCreateViewとかでは、0が返ってきたりListView自体がnullだったりする。しかもよくわからないことに、ログ出力してみたらgetViewがすごい回数呼ばれてて、初めのうちはListViewの高さは0なのに、途中からちゃんとした数字になっていくみたいだった。

class DateSelectDialogListAdapter extends ArrayAdapter<Integer> {
  int mode;
  List<Integer> objects;
  int itemHeight;
  int offset = 0;

  public DateSelectDialogListAdapter(Context context, int resource, List<Integer> objects, int mode) {
    super(context, resource, objects);
    this.mode = mode;
    this.objects = objects;

    itemHeight = getResources().getDimensionPixelSize(R.dimen.date_select_dialog_item_height);
  }

  @Override
  public View getView(int position, View convertView, ViewGroup parent) {
    // 空白の分のViewかどうか
    boolean end = position == 0 || position == objects.size() - 1;

    // dateListの中身を月に合わせる
    if (mode == Calendar.DATE && objects.size() - 2 != CalendarManager.getEndOfMonth(selected)) {
      int excess = objects.size() - 2 - CalendarManager.getEndOfMonth(selected);
      for (int i = 0; i < Math.abs(excess); i++) {
        if (excess > 0) {
          objects.remove(objects.size() - 2);
        } else {
          objects.add(objects.size() - 1, objects.size() - 1);
        }
      }
      notifyDataSetChanged();
    }

    if (convertView == null) {
      convertView = inflater.inflate(R.layout.date_select_dialog_list_item, parent, false);
    }

    if (parent.getHeight() != 0 && offset == 0) {
      offset = (parent.getHeight() - itemHeight) / 2;

      // 選択中のアイテムが真ん中に来るようスクロール
      ((ListView) parent).setSelectionFromTop(calendar2Pos(selected), offset);

      convertView.getLayoutParams().height = end ? offset : itemHeight;
    }

    // TextViewの設定
    // end == trueならVisibilityをView.GONEにしたり、そうじゃなかったら数字を表示したり色を変えたり

    // 選択されている日であれば背景を表示
    convertView.findViewById(R.id.image).setVisibility(
      selected.get(mode) == pos2Calendar(position)
        ? View.VISIBLE
        : View.GONE
    );

    // クリックリスナー
    final int finalPosition = position;
    convertView.setOnClickListener(end ? null : new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);

        // 日付の変更
        selected.set(mode, pos2Calendar(finalPosition));
        updateTitle(getDialog());

        DateSelectDialogListAdapter.this.notifyDataSetChanged();
      }
    });

    return convertView;
  }
}

setSelectionFromTopやりすぎって思ったけど、一回セットしただけでは元に戻ってしまったので何回もやってる。

クリックリスナーでは、標準のDatePickerDialogと同じように、クリックされた際に短くバイブさせる。それがperformHapticFeedbackっていうメソッドなんだけど、ここでは引数であるfeedbackConstantにHapticFeedbackConstants.LONG_PRESSを指定してる。本当はCLOCK_TICKがよかったんだけど、APIレベルが足りないので仕方なく…。CLOCK_TICKが「ビッ」って感じだとすると、LONG_PRESSは「ズォンッ」って勢いでバイブレートしよる。

クリックが遅れて反応する問題

たまに、リストの中身をクリックしたら反応が遅れることがあった。

stackoverflow.com

ここの、LayoutParamsについて語ってる人の回答を見て解決!

convertViewに毎回新しく作ったLayoutParamsをセットしてたのが原因だった。

convertView.getLayoutParams().height = ○;

こうやって設定すればよかったみたい。

ダイアログの選択が完了したときのリスナー

インターフェースの中にメソッド入れといて、

public interface OnDateSelectedListener {
  void onSelect(Calendar calendar);
}

それをフィールドとしておいて、

OnDateSelectedListener onDateSelectedListener;

setOnDateSelectedListenerが呼び出されたときに登録する。

public void setOnDateSelectedListener(OnDateSelectedListener l) {
  this.onDateSelectedListener = l;
}

そしたら、さっき「あとで書く」って書いたOKボタンのリスナーには下のように書ける。

.setPositiveButton("OK", new DialogInterface.OnClickListener() {
  @Override
  public void onClick(DialogInterface dialog, int which) {
    if (onDateSelectedListener != null) {
      onDateSelectedListener.onSelect(今選択されてる日付のCalendarオブジェクト);
    }
  }
})

ダイアログを呼び出す側はこうなる↓

dialog.setOnDateSelectedListener(new DateSelectDialog.OnDateSelectedListener() {
  @Override
  public void onSelect(Calendar calendar) {
    // calendarを使って好きなことをする
  }
});

いろいろと効率悪いところもあるけど、これで上に貼った完成図のようなダイアログができた(*‘▽’)

横向けてもちゃんと表示されてる。

f:id:YaaMaa:20170220134937p:plain

AndroidのCanvasでドーナツチャートを描く

ドーナツ型の円グラフを描くのに結構試行錯誤したので、メモしておく。

完成形

f:id:YaaMaa:20170216032855p:plain

f:id:YaaMaa:20170216032906p:plain

こういう二種類のグラフを作りたい。

clipPathを使う

最初は、グラフの色付きの部分を扇形で描き、その後にclipPathで円の中身をくり抜いていた。アンチエイリアスがかからんかったのは気に入らないけど、それ以外はいい感じだと思っていた。

けど、その円グラフをActivityの上のFragment上のViewPagerの1ページのFragmentの上…という複雑な境遇のCanvasで描くと、clipPathした部分が反転して見えてしまった。

f:id:YaaMaa:20170216033238p:plain

なので、別の方法を探した。

くり抜く

sukohi.blogspot.jp

↑こちらのブログに、よく似たことをやってはる方がいたので、真似させていただいた。

Pathを初めにadd○○を使って塗り、その上から反時計回りでくり抜きたい形をaddしたらできるらしい。

私の場合なら、初めにaddArcしてからaddCircle(CCW)でくり抜いたPathをdrawPathすればいいっていうこと。

これで解決したと思ったんだけど…

f:id:YaaMaa:20170216034549p:plain

こっちのグラフが大惨事になっていた。何事かと思ったら、addArcする際に中心を指定するのを忘れていた。

そして指定しようと思ったら…まず中心を指定するための引数がなかった。drawArcにはuseCenterっていう引数がちゃんと用意されてんのに、addArcにはない笑

なので、この方法も使えない。

他の解決法

stackoverflowで調べてみると、いろんなアドバイスがあった。

「初めにドーナツ型の画像用意しといて、それ切り取ったらいいよ」、「drawArcで代用しーよ」、「PorterDuffXfermode使えばいいよ」、「いっそのことAChartEngine使わせてもらいさ」など。

けど、画像用意するのは気に入らんし、drawArcではくり抜けへんし(上から背景色で塗りつぶすのはできるけど気持ち悪い)、ポーターダフは過去何度も使おうと思ってその度に挫折してきたし、ライブラリ使うのは何か悔しい(わがまま)。

自分で扇形をPathに追加するメソッドを作る

addArcでuseCenterが使えないなら、自分で扇形をPathに追加できるようにすればいいのでは。と思って、作ってみた。

public Path addSector(Path path, float x, float y, float radius, float startAngle, float sweepAngle) {
  path.reset();
  path.moveTo(x, y);
  path.lineTo(
    x + (float) (radius*Math.cos(Math.toRadians(startAngle))),
    y + (float) (radius*Math.sin(Math.toRadians(startAngle)))
  );
  path.arcTo(new RectF(x-radius, y-radius, x+radius, y+radius), startAngle, sweepAngle);
  path.close();
  return path;
}

扇形は英語でsectorっていうらしいので、addSectorって名前にした。まず中心に点を打って、それをstartAngleまで持って行って、そこからsweepAngle分arcToし、閉じる。

ラジアンとかいう言葉久しぶりに聞いて頭痛がした。

これでできたかな?と思ったが、

f:id:YaaMaa:20170216040423p:plain

刺さってる笑

そういえば、敵の敵は味方みたいな感じで、もともと描画してないところをさらにくり抜こうとしたら逆に描画されてしまうんか。

ドーナツ型のパスを自分で作る

よく考えれば、真ん中から円をくり抜くことに固執する必要はなかった。初めから真ん中が抜けているドーナツ形のパスを追加すればいいんやん!

public Path makeDonutPath(float x, float y, float radius, float startAngle, float sweepAngle, float holeProportion) {
  Path donutPath = new Path();
  donutPath.moveTo(
    x + (float) (radius*Math.cos(Math.toRadians(startAngle))),
    y + (float) (radius*Math.sin(Math.toRadians(startAngle)))
  );
  donutPath.arcTo(
    new RectF(x-radius, y-radius, x+radius, y+radius),
    startAngle,
    sweepAngle
  );
  donutPath.lineTo(
    x + (float) (radius*holeProportion*Math.cos(Math.toRadians(startAngle+sweepAngle))),
    y + (float) (radius*holeProportion*Math.sin(Math.toRadians(startAngle+sweepAngle)))
  );
  donutPath.arcTo(
    new RectF(x-radius*holeProportion, y-radius*holeProportion, x+radius*holeProportion, y+radius*holeProportion),
    startAngle+sweepAngle,
    -sweepAngle
  );
  donutPath.close();
  return donutPath;
}

やっとできた…!!( ;∀;)

ネーミングセンスがあれやけど、holeProportionっていうのはドーナツ全体の大きさに対するドーナツの穴の大きさの割合。

ちょっと修正

sweepAngleが0だと、弧の始まりと終わりが同じなので円全体が描画されてしまった。なので、new Path()作った直後に、sweepAngle==0なら即座にその空っぽのPathを持って帰ってもらうようにした。

また、sweepAngle >= 360fのときには、完全な輪っかを作って返すようにした。

強そうな鉱物を作る

人を刺せそうなレベルで強そうな鉱物をCGで作ってみたくなった。

角ばった棒を作ってひたすら土台に挿していったけど、結局12本目あたりで疲れてやめた。

けど、光とかいい感じに設定したら、割と鉱物っぽくなった。

f:id:YaaMaa:20170205054127j:plain

フリーズする問題

レンダラにはいつも通りParthenon Rendererを使ってたんだけど、レンダリングしようとするとたまにフリーズすることがあった。

調べてみたら、特殊な材質ばっかりで構成してたからこういうことになったみたい。

blogs.yahoo.co.jp

ガラス質や鏡面、半透明は美しい材質です。光源もとても便利です。しかしこれだけで構成すると、Parthenonがフリーズします。 .mqoのどこでもいいので、材質が『不透明度1 拡散光1 その他のパラメータ0』の物体を描き入れておきましょう。

Parthenon Rendererを使うにあたってこの方のブログは何度も見てたんだけど、ここの記述だけなぜかすごい見落としてた笑


今見たら、Parthenonのサイト消えてる…!悲しすぎる。ソフトだけはなくさないよう死守しなければ。