2017年1月10日火曜日

v7 Preference Support Library を Material Design にする

フレームワーク

v7 Preference Support Library com.android.support:preference-v7:25.1.0

v14 Preference Support Library com.android.support:preference-v14:25.1.0

v7 Preference Support Library 例

public class SettingsActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); getSupportFragmentManager().beginTransaction() .replace(android.R.id.content, new SettingsFragment()) .commit(); } public static class SettingsFragment extends PreferenceFragmentCompat { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { setPreferencesFromResource(R.xml.pref, rootKey); } } }

Material Design 化 例

<?xml version="1.0" encoding="utf-8"?> <resources> <style name="Theme.Setting"> <item name="android:listSeparatorTextViewStyle">@style/Preference.ListSeparator</item> <item name="preferenceTheme">@style/PreferenceThemeOverlay</item> </style> <style name="Preference.ListSeparator" parent="android:Widget.TextView"> <item name="android:minHeight">48dp</item> <item name="android:gravity">center_vertical</item> <item name="android:textAppearance">@style/TextAppearance.AppCompat.Body2</item> <item name="android:textColor">?colorAccent</item> <item name="android:maxLines">1</item> <item name="android:paddingLeft">16dp</item> <item name="android:paddingRight">16dp</item> </style> </resources> values/styles_preference.xml <?xml version="1.0" encoding="utf-8"?> <resources> <style name="Preference"> <item name="android:layout">@layout/preference_material</item> </style> <style name="Preference.DropDown"> <item name="android:layout">@layout/preference_dropdown_material</item> </style> <style name="Preference.SeekBarPreference"> <item name="android:layout">@layout/preference_widget_seekbar_material</item> <item name="adjustable">true</item> <item name="showSeekBarValue">true</item> </style> <style name="PreferenceFragmentList"> <item name="android:paddingLeft">0dp</item> <item name="android:paddingRight">0dp</item> </style> </resources> values-v17/styles_preference.xml <?xml version="1.0" encoding="utf-8"?> <resources> <style name="PreferenceFragmentList"> <item name="android:paddingStart">0dp</item> <item name="android:paddingEnd">0dp</item> </style> </resources> layout/preference_material.xml <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?android:attr/selectableItemBackground" android:baselineAligned="false" android:focusable="true" android:gravity="center_vertical" android:minHeight="72dp" android:paddingLeft="8dp" android:paddingRight="8dp"> <FrameLayout android:id="@+id/icon_frame" android:layout_width="56dp" android:layout_height="wrap_content"> <android.support.v7.internal.widget.PreferenceImageView android:id="@android:id/icon" android:layout_width="40dp" android:layout_height="40dp" android:layout_gravity="center_horizontal"/> </FrameLayout> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginLeft="8dp" android:layout_marginRight="8dp" android:layout_weight="1" android:orientation="vertical" android:paddingBottom="20dp" android:paddingTop="20dp"> <TextView android:id="@android:id/title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:ellipsize="marquee" android:fadingEdge="horizontal" android:maxLines="1" android:textAppearance="@style/TextAppearance.AppCompat.Subhead" android:textColor="?android:attr/textColorPrimary" tools:text="title"/> <TextView android:id="@android:id/summary" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:maxLines="4" android:textAppearance="@style/TextAppearance.AppCompat.Body1" android:textColor="?android:attr/textColorSecondary" tools:text="summary"/> </LinearLayout> <!-- Preference should place its actual preference widget here. --> <LinearLayout android:id="@android:id/widget_frame" android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_marginLeft="8dp" android:layout_marginRight="8dp" android:gravity="center_vertical" android:orientation="vertical"/> </LinearLayout> layout/preference_dropdown_material.xml <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?android:attr/selectableItemBackground" android:focusable="true" android:gravity="center_vertical" android:minHeight="72dp" android:paddingLeft="8dp" android:paddingRight="8dp"> <Spinner android:id="@+id/spinner" android:layout_width="0dp" android:layout_height="wrap_content" android:visibility="invisible"/> <FrameLayout android:id="@+id/icon_frame" android:layout_width="56dp" android:layout_height="wrap_content"> <android.support.v7.internal.widget.PreferenceImageView android:id="@android:id/icon" android:layout_width="40dp" android:layout_height="40dp" android:layout_gravity="center_horizontal"/> </FrameLayout> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginLeft="8dp" android:layout_marginRight="8dp" android:layout_weight="1" android:orientation="vertical" android:paddingBottom="20dp" android:paddingTop="20dp"> <TextView android:id="@android:id/title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:ellipsize="marquee" android:fadingEdge="horizontal" android:maxLines="1" android:textAppearance="@style/TextAppearance.AppCompat.Subhead" android:textColor="?android:attr/textColorPrimary" tools:text="title"/> <TextView android:id="@android:id/summary" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:maxLines="4" android:textAppearance="@style/TextAppearance.AppCompat.Body1" android:textColor="?android:attr/textColorSecondary" tools:text="summary"/> </LinearLayout> <!-- Preference should place its actual preference widget here. --> <LinearLayout android:id="@android:id/widget_frame" android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_marginLeft="8dp" android:layout_marginRight="8dp" android:gravity="center_vertical" android:orientation="vertical"/> </LinearLayout> layout/preference_widget_seekbar_material.xml <?xml version="1.0" encoding="utf-8"?> <!-- Layout used by SeekBarPreference for the seekbar widget style. --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:clipChildren="false" android:clipToPadding="false" android:gravity="center_vertical" android:minHeight="72dp" android:paddingLeft="8dp" android:paddingRight="8dp"> <ImageView android:id="@+android:id/icon" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:minWidth="@dimen/preference_icon_minWidth"/> <RelativeLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginLeft="8dp" android:layout_marginRight="8dp" android:layout_weight="1" android:clipChildren="false" android:clipToPadding="false" android:paddingBottom="20dp" android:paddingTop="20dp"> <TextView android:id="@+android:id/title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:ellipsize="marquee" android:fadingEdge="horizontal" android:maxLines="1" android:textAppearance="@style/TextAppearance.AppCompat.Subhead" android:textColor="?android:attr/textColorPrimary" tools:text="title"/> <TextView android:id="@android:id/summary" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignLeft="@android:id/title" android:layout_alignStart="@android:id/title" android:layout_below="@android:id/title" android:layout_marginTop="2dp" android:maxLines="4" android:textAppearance="@style/TextAppearance.AppCompat.Body1" android:textColor="?android:attr/textColorSecondary" tools:text="summary"/> <!-- Using UnPressableLinearLayout as a workaround to disable the pressed state propagation to the children of this container layout. Otherwise, the animated pressed state will also play for the thumb in the AbsSeekBar in addition to the preference's ripple background. The background of the SeekBar is also set to null to disable the ripple background --> <android.support.v7.preference.UnPressableLinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignLeft="@android:id/title" android:layout_alignStart="@android:id/title" android:layout_below="@android:id/summary" android:layout_marginTop="2dp" android:clipChildren="false" android:clipToPadding="false"> <SeekBar android:id="@+id/seekbar" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:background="@null" android:clickable="false" android:focusable="false" android:paddingEnd="@dimen/preference_seekbar_padding_end" android:paddingLeft="@dimen/preference_seekbar_padding_start" android:paddingRight="@dimen/preference_seekbar_padding_end" android:paddingStart="@dimen/preference_seekbar_padding_start"/> <TextView android:id="@+id/seekbar_value" android:layout_width="@dimen/preference_seekbar_value_width" android:layout_height="match_parent" android:ellipsize="marquee" android:fadingEdge="horizontal" android:fontFamily="sans-serif-condensed" android:gravity="center" android:maxLines="1" android:textAppearance="@style/TextAppearance.AppCompat.Subhead"/> </android.support.v7.preference.UnPressableLinearLayout> </RelativeLayout> </LinearLayout>

2016年12月25日日曜日

RecyclerView の SnapHelper を調べてみた

この投稿は GeekWomenJapan Advent Calendar 2016 の25日目です。

2016年の11月に droid girls というAndroidの技術に特化した女性コミュニティを立ち上げました。
第2回 Meetup では私が講師を担当してRecyclerViewを取り上げたのですが、その時に SnapHelper というものを発見してしまいました。
今日はこの SnapHelper についての話です。

ちなみに第3回 Meetupでは vector drawable を取り上げます。開催は2017年1月下旬を予定しています。


本題

以下の検証は v25.1.0 で行っています。

RecyclerView.OnFlingListener および関連する SnapHelper などは v24.2.0 で追加されました。


SnapHelper の abstract メソッド

  • public abstract int findTargetSnapPosition(LayoutManager layoutManager, int velocityX, int velocityY)
    • snap 対象の Adapter での位置を返す
  • public abstract View findSnapView(LayoutManager layoutManager)
  • public abstract int[] calculateDistanceToFinalSnap(@NonNull LayoutManager layoutManager, @NonNull View targetView)
    • snap する位置までの距離を返す

PagerSnapHelper

PagerSnapHelper は ViewPager みたいな挙動を実現するのに使います。そのため、RecyclerView も RecyclerView.Adapter が提供する子 View も height と width が MATCH_PARENT である必要があります。

使用例 public class PagerSnapHelperActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_snap_helper); final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view); recyclerView.setHasFixedSize(true); recyclerView.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)); recyclerView.setAdapter(new MyPagerAdapter()); final PagerSnapHelper pagerSnapHelper = new PagerSnapHelper(); pagerSnapHelper.attachToRecyclerView(recyclerView); } private static class MyPagerAdapter extends RecyclerView.Adapter<ViewHolder> { private static final int[] colors = { Color.WHITE, Color.RED, Color.YELLOW, Color.GREEN, Color.CYAN, Color.BLUE, Color.MAGENTA, Color.LTGRAY }; @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { return ViewHolder.create(parent); } @Override public void onBindViewHolder(ViewHolder holder, int position) { holder.textView.setText(String.valueOf(position)); holder.textView.setBackgroundColor(colors[position]); } @Override public int getItemCount() { return colors.length; } } private static class ViewHolder extends RecyclerView.ViewHolder { static ViewHolder create(@NonNull ViewGroup parent) { final TextView textView = new TextView(parent.getContext()); textView.setTextSize(32); textView.setGravity(Gravity.CENTER); textView.setLayoutParams(new RecyclerView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); return new ViewHolder(textView); } final TextView textView; private ViewHolder(@NonNull TextView itemView) { super(itemView); this.textView = itemView; } } } PagerSnapHelper のコード解説

findSnapView() では、RecyclerView の中心位置と各子 View の中心位置との距離を比較して、一番近い子 View を snap 対象としています。
findTargetSnapPosition() では、上端または左端にある子 View を基準に、fling 時の velocity の正負に応じて隣の子 View の位置を返しています。velocity の絶対値は使っていないので、弱く fling しても強く fling しても隣のページに移動するだけです。
calculateDistanceToFinalSnap() では RecyclerView の中心と snap 対象の View の中心との差を返しています。


LinearSnapHelper

LinearSnapHelper は snap 対象の View の中心が RecyclerView の中心に来るように snap します。

使用例 public class LinearSnapHelperActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_snap_helper); final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view); recyclerView.setHasFixedSize(true); recyclerView.setLayoutManager(new LinearLayoutManager(this)); recyclerView.setAdapter(new MyPagerAdapter()); final LinearSnapHelper linearSnapHelper = new LinearSnapHelper(); linearSnapHelper.attachToRecyclerView(recyclerView); } private static class MyPagerAdapter extends RecyclerView.Adapter<ViewHolder> { private static final int[] colors = { Color.WHITE, Color.RED, Color.YELLOW, Color.GREEN, Color.CYAN, Color.BLUE, Color.MAGENTA, Color.LTGRAY, Color.WHITE, Color.RED, Color.YELLOW, Color.GREEN, Color.CYAN, Color.BLUE, Color.MAGENTA, Color.LTGRAY }; @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { return ViewHolder.create(parent); } @Override public void onBindViewHolder(ViewHolder holder, int position) { holder.textView.setText(String.valueOf(position)); holder.textView.setBackgroundColor(colors[position]); } @Override public int getItemCount() { return colors.length; } } private static class ViewHolder extends RecyclerView.ViewHolder { static ViewHolder create(@NonNull ViewGroup parent) { final TextView textView = new TextView(parent.getContext()); textView.setTextSize(32); textView.setGravity(Gravity.CENTER); int height = parent.getMeasuredHeight() / 4; textView.setLayoutParams(new RecyclerView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, height)); return new ViewHolder(textView); } final TextView textView; private ViewHolder(@NonNull TextView itemView) { super(itemView); this.textView = itemView; } } } LinearSnapHelper のコード解説

findSnapView() では、RecyclerView の中心位置と各子 View の中心位置との距離を比較して、一番近い子 View を snap 対象としています。
findTargetSnapPosition() では、velocity の大きさから対応するスクロール量を計算し、1子ビューあたりの大きさからスクロールで移動する子ビューの数を計算し、現在の位置から移動する子ビューの数だけ離れた位置を返しています。
calculateDistanceToFinalSnap() では RecyclerView の中心と snap 対象の View の中心との差を返しています。


上端に snap する SnapHelper

LinearSnapHelper が中心に snap するので、そのコードを参考に center を計算する部分を top に変えれば、上端(start)に snap するようにできます。 public class MyLinearSnapHelper extends SnapHelper { ... @Override public int[] calculateDistanceToFinalSnap( @NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) { int[] out = new int[2]; if (layoutManager.canScrollHorizontally()) { out[0] = distanceToTop(layoutManager, targetView, getHorizontalHelper(layoutManager)); } else { out[0] = 0; } if (layoutManager.canScrollVertically()) { out[1] = distanceToTop(layoutManager, targetView, getVerticalHelper(layoutManager)); } else { out[1] = 0; } return out; } ... @Override public View findSnapView(RecyclerView.LayoutManager layoutManager) { if (layoutManager.canScrollVertically()) { return findTopView(layoutManager, getVerticalHelper(layoutManager)); } else if (layoutManager.canScrollHorizontally()) { return findTopView(layoutManager, getHorizontalHelper(layoutManager)); } return null; } private int distanceToTop(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView, OrientationHelper helper) { final int childTop = helper.getDecoratedStart(targetView); final int containerTop; if (layoutManager.getClipToPadding()) { containerTop = helper.getStartAfterPadding(); } else { containerTop = 0; } return childTop - containerTop; } ... @Nullable private View findTopView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) { int childCount = layoutManager.getChildCount(); if (childCount == 0) { return null; } View closestChild = null; final int top; if (layoutManager.getClipToPadding()) { top = helper.getStartAfterPadding(); } else { top = 0; } int absClosest = Integer.MAX_VALUE; for (int i = 0; i < childCount; i++) { final View child = layoutManager.getChildAt(i); int childTop = helper.getDecoratedStart(child); int absDistance = Math.abs(childTop - top); /** if child top is closer than previous closest, set it as closest **/ if (absDistance < absClosest) { absClosest = absDistance; closestChild = child; } } return closestChild; } ... }


2016年11月24日木曜日

BottomNavigationView で画面回転時に位置を保持するようにしてみた

注意:以下の内容は Design Support Library v25.0.1 時点でのものです

v25.0.0 から Design Support Library に BottomNavigationView が追加されましたが、最新版(v25.0.1)でも画面回転時に選択アイテムの位置を保持してくれず、選択が一番最初のアイテムに戻ってしまう問題があります。しかも選択中のアイテムを変更するAPIも現状では用意されていません。

いちを以下の方法で選択アイテムを変更することはできます。 final View view = findViewById(menuId); if (view != null) { view.performClick(); } でももにょるよね...

本家が対応するまでの間、上記の苦し紛れの方法を駆使した CustomBottomNavigationView を用意しました。これで画面回転時も位置が保持されます。

CustomBottomNavigationView

ついでにこれを使って fragment の入れ替えもちゃんと実装したサンプルを用意したので、ぜひ参考にしてください。

https://github.com/yanzm/BottomNavigationSample


本家で早く対応してください。