[Android] PreferenceFragmentCompatで設定画面を作る

今回はAppCompatActivity+PreferenceFragmentCompatを使って設定画面を作ります。

完成図


今回は

  • Fragmentの遷移
  • 例としてListPreferenceの定義
  • その設定の適用

みたいな感じです。

コード

詳しいことはコード内のコメントに書きましたので参照ください。

MainActivity.java

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // Buttonクリックで設定画面へ
        findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                startActivity(new Intent(MainActivity.this, SettingsActivity.class));
            }
        });
    }

    @Override
    protected void onStart() {
        super.onStart();
        // レイアウトルートの背景をテーマ設定の値によって変更
        RelativeLayout root = findViewById(R.id.root);
        SharedPreferences defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
        switch (defaultSharedPreferences.getString("preference_theme", getString(R.string.default_value_preference_theme))) {
            case "light":
                root.setBackgroundColor(Color.parseColor("#FFFFFF"));
                break;
            case "dark":
                root.setBackgroundColor(Color.parseColor("#000000"));
                break;
        }
    }

}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/root"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.hatenablog.mnoqlo.sampleapplication.MainActivity">

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true"
        android:gravity="center"
        android:text="設定画面を開く" />

</RelativeLayout>

SettingsActivity.java

public class SettingsActivity extends AppCompatActivity implements PreferenceFragmentCompat.OnPreferenceStartScreenCallback {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_settings);

        // Toolbarの設定
        Toolbar toolbar = findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        getSupportActionBar().setDisplayHomeAsUpEnabled(true);

        // savedInstanceStateがnullでない場合は現在のFragmentが自動で復元されるのでこの処理は不要
        if (savedInstanceState == null) {
            // トップ画面のFragmentを表示
            getSupportFragmentManager()
                    .beginTransaction()
                    .replace(R.id.fragment_container, SettingsFragment.newInstance("preference_root"))
                    .commit();
        }
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        if (item.getItemId() == android.R.id.home) {
            // ActionBarの矢印がクリックされたとき、Backボタンと同等の処理をする
            // 前のFragmentに戻るのではなくActivity自体を終了させたい場合は代わりに finish();
            onBackPressed();
            return true;
        }
        return super.onOptionsItemSelected(item);
    }

    // PreferenceScreenがクリックされた時に呼び出されます
    @Override
    public boolean onPreferenceStartScreen(PreferenceFragmentCompat caller, PreferenceScreen pref) {
        // Fragmentの切り替えと、addToBackStackで戻るボタンを押した時に前のFragmentに戻るようにする
        getSupportFragmentManager()
                .beginTransaction()
                .replace(R.id.fragment_container, SettingsFragment.newInstance(pref.getKey()))
                .addToBackStack(null)
                .commit();
        return true;
    }

}

activity_settings.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.hatenablog.mnoqlo.sampleapplication.SettingsActivity">

    <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:theme="@style/ToolbarTheme" />

    <FrameLayout
        android:id="@+id/fragment_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@+id/toolbar" />

</RelativeLayout>

SettingsFragment.java

public class SettingsFragment extends PreferenceFragmentCompat {

    public static SettingsFragment newInstance(String rootKey) {
        SettingsFragment fragment = new SettingsFragment();
        Bundle bundle = new Bundle();
        // 第1引数をPreferenceFragmentCompat.ARG_PREFERENCE_ROOTとすることでonCreatePreferencesの第2引数がここでputしたrootKeyになります
        bundle.putString(PreferenceFragmentCompat.ARG_PREFERENCE_ROOT, rootKey);
        fragment.setArguments(bundle);
        return fragment;
    }

    @Override
    public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
        setPreferencesFromResource(R.xml.preferences, rootKey);

        // クリックされたPreferenceScreen毎にPreferenceのカスタマイズなど
        switch (rootKey) {
            case "preference_appearance":
                onCreateAppearancePreferences();
                break;
            case "preference_others":
                break;
        }
    }

    private void onCreateAppearancePreferences() {
        // テーマ設定の現在の値をSummaryに表示
        ListPreference themePreference = (ListPreference) findPreference("preference_theme");
        themePreference.setSummary(themePreference.getEntry());
        themePreference.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
            @Override
            public boolean onPreferenceChange(Preference preference, Object newValue) {
                int indexOfValue = themePreference.findIndexOfValue(String.valueOf(newValue));
                themePreference.setSummary(indexOfValue >= 0 ? themePreference.getEntries()[indexOfValue] : null);
                return true;
            }
        });
    }

    @Override
    public void onResume() {
        super.onResume();
        // ActionBarのタイトルに現在表示中のPreferenceScreenのタイトルをセット
        String rootKey = getArguments().getString(PreferenceFragmentCompat.ARG_PREFERENCE_ROOT);
        getActivity().setTitle(findPreference(rootKey).getTitle());
    }

}

preferences.xml

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:title="設定"
    android:key="preference_root">

    <PreferenceScreen
        android:title="外観設定"
        android:key="preference_appearance"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">

        <ListPreference
            android:title="テーマ"
            android:key="preference_theme"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:entries="@array/entries_preference_theme"
            android:entryValues="@array/entry_values_preference_theme"
            android:defaultValue="@string/default_value_preference_theme" />

    </PreferenceScreen>

    <PreferenceScreen
        android:title="その他の設定"
        android:key="preference_others"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">

        <PreferenceScreen />

    </PreferenceScreen>

</PreferenceScreen>

arrays.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <string-array name="entries_preference_theme">
        <item>ライト</item>
        <item>ダーク</item>
    </string-array>
    <string-array name="entry_values_preference_theme">
        <item>light</item>
        <item>dark</item>
    </string-array>

</resources>

strings.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">Sample Application</string>

    <string name="default_value_preference_theme">light</string>
</resources>

styles.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

    <style name="ToolbarTheme" parent="ThemeOverlay.AppCompat.Dark.ActionBar">
        <item name="android:background">@color/colorPrimary</item>
    </style>
</resources>

build.gradle

/*
省略
*/
dependencies {
    implementation 'androidx.preference:preference:最新バージョン'
    // 試してはいませんが、AndroidXではなくSupport Libraryの場合は implementation 'com.android.support:preference-v14:最新バージョン'
}

androidxの方のバージョンはたぶんここ
Support Libraryの方のバージョンはたぶんここ


間違い等ありましたら、コメントしてくださると助かります🙏🙏

[Android] RecyclerViewのスクロール直後にクリックが動作しない

事象

CoordinatorLayout + AppBarLayout + RecyclerViewのようなレイアウトにおいて、RecyclerViewを端までスクロールさせた際、直後数秒間1回目のタップが動作しない不具合が発生しました。

原因

コンポーネント自体の不具合のようです。
AOSP issue 66996774

解決策

方法1

Support Libraryのバージョン 27.0.1 で修正されたとのアナウンスがありました(https://developer.android.com/topic/libraries/support-library/revisions#bug-fixes_8)
が、アップデートしても改善されないとの声が多くあります。
一応build.gradleよりアップデートを試してみて、改善されない場合は以下の方法をお試しください。

方法2

以下のリンク先のようにAppBarLayout.Behaviorを修正する方法です。基本的にはこちらで対応してください。
Fixed AppBarLayout.Behavior for https://issuetracker.google.com/66996774 · GitHub

方法3

ほとんど方法2と変わりませんが、以下のようにAppBarLayoutを拡張してもいいかもしれません(推奨はしません)。

FixedAppBarLayout.java

public class FixedAppBarLayout extends AppBarLayout {

    public FixedAppBarLayout(Context context) {
        super(context, null);
    }

    public FixedAppBarLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public void setLayoutParams(ViewGroup.LayoutParams params) {
        if (params instanceof CoordinatorLayout.LayoutParams) {
            ((CoordinatorLayout.LayoutParams) params).setBehavior(new FixedAppBarLayoutBehavior());
        }
        super.setLayoutParams(params);
    }

    // 以下、方法2と同じ
    public static class FixedAppBarLayoutBehavior extends AppBarLayout.Behavior {

        public FixedAppBarLayoutBehavior() {
            super();
        }

        public FixedAppBarLayoutBehavior(Context context, AttributeSet attrs) {
            super(context, attrs);
        }

        @Override
        public void onNestedScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target,
                                   int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
            super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed,
                    dxUnconsumed, dyUnconsumed, type);
            stopNestedScrollIfNeeded(dyUnconsumed, child, target, type);
        }

        @Override
        public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child,
                                      View target, int dx, int dy, int[] consumed, int type) {
            super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type);
            stopNestedScrollIfNeeded(dy, child, target, type);
        }

        private void stopNestedScrollIfNeeded(int dy, AppBarLayout child, View target, int type) {
            if (type == ViewCompat.TYPE_NON_TOUCH) {
                final int currOffset = getTopAndBottomOffset();
                if ((dy < 0 && currOffset == 0)
                        || (dy > 0 && currOffset == -child.getTotalScrollRange())) {
                    ViewCompat.stopNestedScroll(target, ViewCompat.TYPE_NON_TOUCH);
                }
            }
        }

    }

}

layout.xml

<パッケージ.FixedAppBarLayout
    android:layout_height="..."
    android:layout_width="...">

[Twitter] 6種類のアイコンサイズについて

Twitterのアイコン(プロフィール画像)には6種類のサイズがあります。

サイズ 解像度 URL例
mini 24x24 https://pbs.twimg.com/profile_images/1034504113826947073/WlBRMRI6_mini.jpg
https://pbs.twimg.com/profile_images/1034504113826947073/WlBRMRI6.jpg_mini
normal 48x48 https://pbs.twimg.com/profile_images/1034504113826947073/WlBRMRI6_normal.jpg
https://pbs.twimg.com/profile_images/1034504113826947073/WlBRMRI6.jpg_normal
bigger 73x73 https://pbs.twimg.com/profile_images/1034504113826947073/WlBRMRI6_bigger.jpg
https://pbs.twimg.com/profile_images/1034504113826947073/WlBRMRI6.jpg_bigger
200x200 200x200 https://pbs.twimg.com/profile_images/1034504113826947073/WlBRMRI6_200x200.jpg
https://pbs.twimg.com/profile_images/1034504113826947073/WlBRMRI6.jpg_200x200
400x400 400x400 https://pbs.twimg.com/profile_images/1034504113826947073/WlBRMRI6_400x400.jpg
https://pbs.twimg.com/profile_images/1034504113826947073/WlBRMRI6.jpg_400x400
original 512x512 https://pbs.twimg.com/profile_images/1034504113826947073/WlBRMRI6.jpg

解像度は元画像の解像度によって異なる場合があるので注意です。
それと、以前originalサイズで画像の取得自体できないユーザーがいたので、自動化する場合は例外処理をしましょう。

[Twitter API] OAuthのCallback URLホワイトリスト化に対応する

概要

2018/06/12よりTwitter OAuthのCallback URL関係で変更がありました。
この変更に伴い、以下のようなエラーが発生するようになりました。

code: 415
Callback URL not approved for this client application. Approved callback URLs can be adjusted in your application settings.

変更内容はCallback URLs — Twitter Developersに書いてあるんですが、変更後は

みたいです。
また、次のような場合には注意してください。

Callback URLにクエリ文字列を使用したい場合

例えばCallback URLに『https://yourdomain.com?source=twitter』と設定したい場合は、次のようにします。

  1. developer.twitter.com/en/appsのCallback URL設定に『https://yourdomain.com』と登録
  2. POST oauth/request_tokenのcallback_url(oauth_callback)パラメータを『https://yourdomain.com?source=twitter』と指定

Callback URLにlocalhostを使用したい場合

developer.twitter.com/en/appsのCallback URL設定はホスト名が『localhost』だと登録できません。
代わりに、次のいずれかの方法で登録します。

いずれの方法でもポート番号を指定することができるようです(例: http://127.0.0.1:2000)

Callback URLにモバイルアプリへのリンクを設定したい場合

例えばCallback URLに『example://authorize』と設定したい場合は、次のようにします。

  1. developer.twitter.com/en/appsのCallback URL設定に『example://』と登録
  2. POST oauth/request_tokenのcallback_url(oauth_callback)パラメータを『example://authorize』と指定

Twitter Kitを使用している場合

Twitter Kitを使用している場合、冒頭のエラーまたは以下のエラーが発生する場合があります。

Failed to get request token

その場合、developer.twitter.com/en/appsのCallback URL設定を以下のように登録します。

twittersdk://

twitterkit-{アプリのCONSUMERKEY}://

Callback URL設定数が上限に達した場合

developer.twitter.com/en/appsで登録できるCallback URLの数には10個という上限があります。
その上限に達した場合は、Callback URLを単一のアドレスにまとめてPOST oauth/request_tokenでクエリ文字列を使用してください、とのことですたぶん。
自信が無いので引用しておきます。

Need more than 10 callback URLs?
There is a hard limit of 10 callback URLs in the Twitter apps dashboard. Please make sure to combine your callback URLs into a single address and use query strings in your oauth/request_token request.

Callback URLs — Twitter Developers

[Twitter4J] Twitter APIのJSONを扱う

java

// JSONを保存するよう設定する
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();
configurationBuilder.setJSONStoreEnabled(true);
TwitterFactory twitterFactory = new TwitterFactory(configurationBuilder.build());
Twitter twitter = twitterFactory.getInstance();

// 例としてGET statuses/show/:idを行っています
Status status = twitter.showStatus(997324372867076096L);

// 保存されているJSONを取得
String statusJSON = TwitterObjectFactory.getRawJSON(status);

// こうすることでJSONからオブジェクトを生成することもできます
Status status1 = TwitterObjectFactory.createStatus(statusJSON);


上記コードではJSONを保存する設定をConfigurationBuilderクラスから行っていますが、
下記のようにtwitter4j.propertiesファイルから行うこともできます。

twitter4j.properties

jsonStoreEnabled=true

java

// 例としてGET followers/listを行っています
PagableResponseList<User> followersList = TwitterFactory.getSingleton().getFollowersList("mnoqlo", -1);

// 保存されているJSONを取得
String userJSON = TwitterObjectFactory.getRawJSON(followersList.get(0));

// JSONからオブジェクトを生成
User user = TwitterObjectFactory.createUser(userJSON);

jsonStoreEnabledはメモリを食うためデフォルトでfalseになっているとのことなので、
JSONが必要な時に動的に設定できる前者のやり方がいいかもしれません(JSONを取得したいことが多い場合などは別)。

[Android] 『この画像でGoogle検索』を実装する

ぶっちゃけ記事にするほどでもないんですけど、それ故か検索してもそれらしい記事が見つからなかったので書いておきます。

String imageUrl = "https://pbs.twimg.com/media/DS_eMjtVoAEpIwQ.jpg";    // TODO: 検索したい画像のURLと置き換える
Uri uri = Uri.parse("https://images.google.com/searchbyimage?image_url=" + imageUrl);
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
startActivity(intent);

これを実行するとブラウザが起動し、imageUrlに入れた画像URLから『画像で検索』がされます。
ただこれだと画像URLが分かる場合のみなんですけど、ストレージにある画像から検索する機能をわざわざ実装する必要はないでしょうし、URLが分からない外部の画像を扱う機会ってありますかね?
ぽんこつだからわかんない!
いじょう!


P.S.
サンプルコードの画像URLそのままで検索するとおにくの画像出てきちゃうので気をつけてね💕

[Android] PreferenceFragmentで設定画面を作る

非推奨

PreferenceFragmentはAPI level 28で非推奨になりました。
代わりにPreferenceFragmentCompatを使ってください。
PreferenceFragmentCompatについてはこちらで↓
mnoqlo.hatenablog.com




設定項目をタップするとページ(Fragment)を切り替える設定画面(?)を作ってみます。

完成図

f:id:mnoqlo:20171028064808p:plain

foo設定をクリックすると
f:id:mnoqlo:20171028064815p:plain

コード

SettingsActivity.java

public class SettingsActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_settings);

        // Toolbarにタイトルを設定
        ((Toolbar) findViewById(R.id.toolbar)).setTitle("設定");

        // fragment_container(FrameLayout)部分にMainPreferenceFragmentを挿入
        getFragmentManager().beginTransaction().replace(R.id.fragment_container, MainPreferenceFragment.newInstance()).commit();
    }

}


activity_settings.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!-- themeは各自のテーマを -->
    <android.support.v7.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:theme="@style/ToolbarTheme" />

    <FrameLayout
        android:id="@+id/fragment_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@+id/toolbar" />

</RelativeLayout>


MainPreferenceFragment.java

public class MainPreferenceFragment extends PreferenceFragment implements Preference.OnPreferenceClickListener {

    public static MainPreferenceFragment newInstance() {
        return new MainPreferenceFragment();
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        addPreferencesFromResource(R.xml.preference_main);

        // findPreferenceでkeyに対応するPreferenceを取得し、それがクリックされた時のリスナーを登録する
        // リスナーはこのクラス自体にimplementsしてるのでthis
        findPreference("preference_foo").setOnPreferenceClickListener(this);
        findPreference("preference_bar").setOnPreferenceClickListener(this);
    }


    // リスナー部分
    @Override
    public boolean onPreferenceClick(Preference preference) {
        // keyを見てクリックされたPreferenceを特定
        switch (preference.getKey()) {
            case "preference_foo":
                transitionFragment(FooPreferenceFragment.newInstance());
                break;
            case "preference_bar":
                transitionFragment(BarPreferenceFragment.newInstance());
                break;
        }
        // おそらくですが、クリックした時に反応するリスナーが複数ある場合、ここをtrueにするとこれより後のリスナーが反応しなくなります
        return false;
    }

    private void transitionFragment(PreferenceFragment nextPreferenceFragment) {
        // replaceによるFragmentの切り替えと、addToBackStackで戻るボタンを押した時に前のFragmentに戻るようにする
        getFragmentManager()
                .beginTransaction()
                .addToBackStack(null)
                .replace(R.id.fragment_container, nextPreferenceFragment)
                .commit();
    }

}


Preferenceのxmlファイルは一般的にres/xml以下に作成するみたいですたぶん
preference_main.xml

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
    xmlns:android="http://schemas.android.com/apk/res/android">

    <PreferenceCategory
        android:title="設定">

        <PreferenceScreen
            android:title="foo設定"
            android:key="preference_foo"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

        <PreferenceScreen
            android:title="bar設定"
            android:key="preference_bar"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

    </PreferenceCategory>

</PreferenceScreen>


FooPreferenceFragment.java

public class FooPreferenceFragment extends PreferenceFragment {

    public static FooPreferenceFragment newInstance() {
        return new FooPreferenceFragment();
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        addPreferencesFromResource(R.xml.preference_foo);
    }

}


preference_foo.xml

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
    xmlns:android="http://schemas.android.com/apk/res/android">

    <PreferenceCategory
        android:title="foo設定">

        <PreferenceScreen
            android:title="foo"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

    </PreferenceCategory>

</PreferenceScreen>


以下barはfooと名前が変わっただけです
BarPreferenceFragment.java

public class BarPreferenceFragment extends PreferenceFragment {

    public static BarPreferenceFragment newInstance() {
        return new BarPreferenceFragment();
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        addPreferencesFromResource(R.xml.preference_bar);
    }

}


preference_bar.xml

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
    xmlns:android="http://schemas.android.com/apk/res/android">

    <PreferenceCategory
        android:title="bar設定">

        <PreferenceScreen
            android:title="bar"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />

    </PreferenceCategory>

</PreferenceScreen>

今回はPreferenceScreenをクリックした時のFragmentページ遷移のみです。
より具体的な設定項目の作り方に関しては、それぞれのPreference名(「ListPreference」や「EditTextPreference」等)で検索してみてください。
もし要望があればそちらも書きます。
そりでわ皆さん、良きAndroidライフを~~~