2015年9月15日火曜日

AppCompat でカラーの Raised Button を設定する正しい方法

http://www.google.com/design/spec/components/buttons.html#buttons-flat-raised-buttons

まとめ
  • ボタンの文字は android:textAppearanceButton(文字サイズは 14sp、文字色は textColorPrimary)
  • ボタンの色は colorButtonNormal
    • 4以下にも適用される
    • テーマ直下の属性なので、全部のボタンに適用されてしまうのが難点

1. 背景が Light でボタンのテキストが Dark <style name="AppTheme" parent="Theme.AppCompat.Light"> <item name="colorButtonNormal">#2196F3</item> </style>

2. 背景が Light でボタンのテキストが Dark(Disabled 時はグレー) <style name="AppTheme" parent="Theme.AppCompat.Light"> <item name="colorButtonNormal">@color/button_color</item> </style> res/color/button_color.xml <?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:color="#1E000000" android:state_enabled="false" /> <item android:color="#2196F3" /> </selector>

3. 背景が Dark でボタンのテキストが Light <style name="AppTheme" parent="Theme.AppCompat"> <item name="colorButtonNormal">#2196F3</item> </style>

4. 背景が Dark でボタンのテキストが Light(Disabled 時はグレー) <style name="AppTheme" parent="Theme.AppCompat"> <item name="colorButtonNormal">@color/button_color</item> </style> res/color/button_color.xml <?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:color="#1EFFFFFF" android:state_enabled="false" /> <item android:color="#2196F3" /> </selector>

5. 背景が Light でボタンのテキストが Light <style name="AppTheme" parent="Theme.AppCompat.Light"> <item name="colorButtonNormal">#2196F3</item> <item name="android:textAppearanceButton">@style/TextAppearance.Button</item> </style> <style name="TextAppearance.Button" parent="Base.TextAppearance.AppCompat.Button"> <item name="android:textColor">?android:attr/textColorPrimaryInverse</item> </style>

6. 背景が Light でボタンのテキストが Light(Disabled 時はグレー) <style name="AppTheme" parent="Theme.AppCompat.Light"> <item name="colorButtonNormal">@color/button_color</item> <item name="android:textAppearanceButton">@style/TextAppearance.Button</item> </style> <style name="TextAppearance.Button" parent="Base.TextAppearance.AppCompat.Button"> <item name="android:textColor">?android:attr/textColorPrimaryInverse</item> </style> res/color/button_color.xml <?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:color="#1E000000" android:state_enabled="false" /> <item android:color="#2196F3" /> </selector>

7. 背景が Light でボタンのテキストが Light(Disabled 時は文字もグレー) <style name="AppTheme" parent="Theme.AppCompat.Light"> <item name="colorButtonNormal">@color/button_color</item> <item name="android:textAppearanceButton">@style/TextAppearance.Button</item> </style> <style name="TextAppearance.Button" parent="Base.TextAppearance.AppCompat.Button"> <item name="android:textColor">@color/button_text_color</item> </style> res/color/button_color.xml <?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:color="#1E000000" android:state_enabled="false" /> <item android:color="#2196F3" /> </selector> res/color/button_text_color.xml <?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:color="@color/primary_text_disabled_material_light" android:state_enabled="false" /> <item android:color="@color/primary_text_default_material_dark" /> </selector>


解説

Widget.AppCompat.Button

values.xml <style name="Widget.AppCompat.Button" parent="Base.Widget.AppCompat.Button"/>

Base.Widget.AppCompat.Button

values.xml <style name="Base.Widget.AppCompat.Button" parent="android:Widget"> <item name="android:background">@drawable/abc_btn_default_mtrl_shape</item> <item name="android:textAppearance">?android:attr/textAppearanceButton</item> <item name="android:minHeight">48dip</item> <item name="android:minWidth">88dip</item> <item name="android:focusable">true</item> <item name="android:clickable">true</item> <item name="android:gravity">center_vertical|center_horizontal</item> </style> values-v21.xml <style name="Base.Widget.AppCompat.Button" parent="android:Widget.Material.Button"/> <style name="Widget.Material.Button"> <item name="background">@drawable/btn_default_material</item> <item name="textAppearance">?attr/textAppearanceButton</item> <item name="minHeight">48dip</item> <item name="minWidth">88dip</item> <item name="stateListAnimator">@anim/button_state_list_anim_material</item> <item name="focusable">true</item> <item name="clickable">true</item> <item name="gravity">center_vertical|center_horizontal</item> </style> これを見ると、ボタンの文字は ?attr/textAppearanceButton を参照していることがわかる


textAppearanceButton

values.xml <style name="Theme.AppCompat" parent="Base.Theme.AppCompat"/> <style name="Base.Theme.AppCompat" parent="Base.V7.Theme.AppCompat"> <style name="Base.V7.Theme.AppCompat" parent="Platform.AppCompat"> <item name="android:textAppearanceButton">@style/TextAppearance.AppCompat.Button</item> ... </style> <style name="Base.V7.Theme.AppCompat.Light" parent="Platform.AppCompat.Light"> <item name="android:textAppearanceButton">@style/TextAppearance.AppCompat.Button</item> </style> <style name="TextAppearance.AppCompat.Button" parent="Base.TextAppearance.AppCompat.Button"/> <style name="Base.TextAppearance.AppCompat.Button"> <item name="android:textSize">@dimen/abc_text_size_button_material</item> <item name="textAllCaps">true</item> <item name="android:textColor">?android:textColorPrimary</item> </style> values-v14.xml <style name="Base.TextAppearance.AppCompat.Button"> <item name="android:textSize">@dimen/abc_text_size_button_material</item> <item name="android:textAllCaps">true</item> <item name="android:textColor">?android:textColorPrimary</item> </style> 文字サイズは @dimen/abc_text_size_button_material (=14sp)、文字色は ?attr/textColorPrimary である。

values-v21.xml <style name="Base.Theme.AppCompat" parent="Base.V21.Theme.AppCompat"/> <style name="Base.V21.Theme.AppCompat" parent="Base.V7.Theme.AppCompat"> ... </style> <style name="Base.TextAppearance.AppCompat.Button" parent="android:TextAppearance.Material.Button"/> styles_material.xml <style name="TextAppearance.Material.Widget.Button" parent="TextAppearance.Material.Button" /> <style name="TextAppearance.Material.Button"> <item name="textSize">@dimen/text_size_button_material</item> <item name="fontFamily">@string/font_family_button_material</item> <item name="textAllCaps">true</item> <item name="textColor">?attr/textColorPrimary</item> </style> 文字サイズは @dimen/text_size_button_material (=14sp)、文字色は ?attr/textColorPrimary である。


btn_default_material <?xml version="1.0" encoding="utf-8"?> <!-- Copyright (C) 2014 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> <ripple xmlns:android="http://schemas.android.com/apk/res/android" android:color="?attr/colorControlHighlight"> <item android:drawable="@drawable/btn_default_mtrl_shape" /> </ripple>

btn_default_mtrl_shape <?xml version="1.0" encoding="utf-8"?> <!-- Copyright (C) 2014 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> <!-- Used as the canonical button shape. --> <inset xmlns:android="http://schemas.android.com/apk/res/android" android:insetLeft="@dimen/button_inset_horizontal_material" android:insetTop="@dimen/button_inset_vertical_material" android:insetRight="@dimen/button_inset_horizontal_material" android:insetBottom="@dimen/button_inset_vertical_material"> <shape android:shape="rectangle"> <corners android:radius="@dimen/control_corner_material" /> <solid android:color="?attr/colorButtonNormal" /> <padding android:left="@dimen/button_padding_horizontal_material" android:top="@dimen/button_padding_vertical_material" android:right="@dimen/button_padding_horizontal_material" android:bottom="@dimen/button_padding_vertical_material" /> </shape> </inset> これを見ると ?attr/colorButtonNormal が solid の color に指定されていることがわかる


AndroidJUnitRunner で Toast や PopupWindow を表示するには

Toast、Dialog、PopupWindow など別の Window を使う操作をテストメソッド内で行うと、Handler が作れないと言われてエラーになります (PopupWindow を内包したユーティリティクラスのテストで困りました)。

例えば以下のテストを実行すると、Toast 部分で

java.lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare()

になります。 @RunWith(AndroidJUnit4.class) @LargeTest public class SimpleMenuPopupTest { @Test public void showToastTest() { Context context = InstrumentationRegistry.getTargetContext(); Toast.makeText(context, "Hello Android.", Toast.LENGTH_SHORT).show(); } }
このような Handler の生成が必要な操作では Instrumentation.runOnMainSync() を利用します。 @RunWith(AndroidJUnit4.class) @LargeTest public class SimpleMenuPopupTest { @Test public void showToastTest() { InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { Context context = InstrumentationRegistry.getTargetContext(); Toast.makeText(context, "Hello Android.", Toast.LENGTH_SHORT).show(); } }); } } これでエラーにならなくなりました。


Dialog の場合、InstrumentationRegistry.getTargetContext() では Window Token が無いと言われてエラーになるので、ActivityTestRule から取得した Activity とかを使います。

android.view.WindowManager$BadTokenException: Unable to add window -- token null is not for an application


Espresso のコードはテストメソッドの方に書きます。 @RunWith(AndroidJUnit4.class) @LargeTest public class SimpleMenuPopupTest { @Test public void showPopupTest() { InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { Context context = activityRule.getActivity(); new AlertDialog.Builder(context) .setMessage("Hello Android.") .show(); } }); onView(withText("Hello Android.")).check(matches(isDisplayed())); } }


参考


2015年9月10日木曜日

Butter Knife のフィールドやメソッドが Lint に Unused declaration と言われないようにする方法

以下のように apt のプラグインを追加します。
https://bitbucket.org/hvisser/android-apt
このプラグインを追加すると、gradle のソースフォルダとして認識される場所に Butter Knife で生成されるコードが格納されます。
そのため、Butter Knife で生成されたクラスも Lint から見えるようになり、Lint が怒らなくなります。やったー。


ルートプロジェクトの build.gradle buildscript { repositories { jcenter() } dependencies { classpath 'com.android.tools.build:gradle:1.3.1' classpath 'com.neenbedankt.gradle.plugins:android-apt:1.8' // これを追加 } }

モジュールの build.gradle apply plugin: 'com.android.application' apply plugin: 'com.neenbedankt.android-apt' // これを追加 ...


minSdkVersion が自分のアプリより大きいライブラリを利用する

自分のアプリの minSdkVersion が 10 のときに、minSdkVersion が 14 のライブラリを利用しようとすると、以下のようなエラーがでてビルドに失敗します。
> Manifest merger failed : uses-sdk:minSdkVersion 10 cannot be smaller than version 14 declared in library [利用しているライブラリ] /.../AndroidManifest.xml
   Suggestion: use tools:overrideLibrary="利用しているライブラリ" to force usage

エラーメッセージの Suggestion にある通りに tools:overrideLibrary="利用しているライブラリ" を追加すればいいのですが、問題は追加する場所です。
uses-sdk タグに追加します。 <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="net.yanzm.sample"> <uses-sdk tools:overrideLibrary="利用しているライブラリ" /> ... </manifest>


ライブラリで指定された AndroidManifest の属性を置き換える

利用しているライブラリで android:allowBackup="true" が指定されているときに、自分のアプリで android:allowBackup="false" を指定するとマニフェストのマージに失敗してビルドエラーになります。
> Manifest merger failed : Attribute application@allowBackup value=(false) from AndroidManifest.xml:22:9-36
   is also present at [利用しているライブラリ] AndroidManifest.xml:9:18-44 value=(true)
   Suggestion: add 'tools:replace="android:allowBackup"' to  element at AndroidManifest.xml:21:5-83:19 to override

エラーメッセージの Suggestion にあるように tools:replace="android:allowBackup" をつけるとアプリ側の設定が利用されエラーが解消します。 <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="net.yanzm.sample"> <application android:allowBackup="false" tools:replace="android:allowBackup" ...> ... </application> </manifest> その他の tools 属性なども Attributes markers examples に載っています。


2015年9月9日水曜日

Google App Engine / Go で Google Cloud Storage に画像をアップロードする

How to upload image to Google Cloud Storage with Google App Engine / Go.

この情報は2015年9月9日時点のものです。appengine.Context, context.Context 問題は過渡期のようなので今後インタフェースが変わる可能性があります。

File API が終了になったので調べたけどあんまり情報がなく苦労したのでメモとして残しておく

storage#NewWriter に渡す Context は appengine#Context ではなく context#Context なので次のようにするとエラーになります。

import ( "appengine" "google.golang.org/cloud/storage" ... ) func uploadImage(w http.ResponseWriter, r *http.Request) { ... c := appengine.NewContext(r) wc := storage.NewWriter(c, bucketName, fileName) wc.ContentType = "image/jpg" if _, err := wc.Write(data); err != nil { ... } ... } google.golang.org/appengine の NewContext は context#Context を返すため、こちらを利用します。このワークアラウンドは https://github.com/golang/oauth2/#app-engine を参考にしています。 import ( "appengine" "fmt" "io/ioutil" "net/http" "strings" "golang.org/x/net/context" "golang.org/x/oauth2" "golang.org/x/oauth2/google" newappengine "google.golang.org/appengine" newurlfetch "google.golang.org/appengine/urlfetch" "google.golang.org/cloud" "google.golang.org/cloud/storage" ) func uploadImage(w http.ResponseWriter, r *http.Request) { file, fileHeader, err := r.FormFile("image_file") if err != nil { fmt.Fprint(w, "no image") // no image return } defer file.Close() data, err := ioutil.ReadAll(file) if err != nil { fmt.Fprint(w, "cloud not upload image") return } var mimeType string if strings.HasSuffix(filename, ".png") { mimeType = "image/png" } else if strings.HasSuffix(filename, ".jpeg") { mimeType = "image/jpg" } else { mimeType = "image/jpg" } bucketName := "mybucketname" fileName := fileHeader.Filename c := appengine.NewContext(r) ctx := newappengine.NewContext(r) hc := &http.Client{ Transport: &oauth2.Transport{ Source: google.AppEngineTokenSource(ctx, storage.ScopeFullControl), Base: &newurlfetch.Transport{Context: ctx}, }, } ctx2 := cloud.NewContext(appengine.AppID(c), hc) wc := storage.NewWriter(ctx2, bucketName, fileName) wc.ContentType = mimeType if _, err := wc.Write(data); err != nil { fmt.Fprint(w, "cloud not upload image") return } if err := wc.Close(); err != nil { return }
google.golang.org/cloud/storage などは GOPAH を設定して $ go get -u google.golang.org/cloud/storage でインストールしておきます


参考


2015年9月7日月曜日

FragmentArgs 使ってみた

FragmentArgs

Fragment 生成時に値を渡すには、引数付きのコンストラクタを用意するのではなく、Fragment.setArguments() で値を渡すのがベストプラクティスです。

FragmentArgs はアノテーションを付けたフィールドからFragmentのビルダークラスを生成してくれるライブラリです。

以下の内容は2015年9月7日時点の状態をもとにしています。



設定

Project の build.gradle buildscript { repositories { jcenter() } dependencies { classpath 'com.android.tools.build:gradle:1.3.1' // apt を使うので以下が必要 classpath 'com.neenbedankt.gradle.plugins:android-apt:1.7' } } allprojects { repositories { jcenter() } } app の build.gradle apply plugin: 'com.android.application' // apt を使うので以下が必要 apply plugin: 'com.neenbedankt.android-apt' android { ... } dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) // 以下が必要 compile 'com.hannesdorfmann.fragmentargs:annotation:2.1.2' apt 'com.hannesdorfmann.fragmentargs:processor:2.1.2' }

使い方 public class MainFragment extends Fragment { @Arg int userId; @Arg String name; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // @Arg を付けたフィールドに getArguments() から値が代入される FragmentArgs.inject(this); } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); Toast.makeText(getActivity(), "userId = " + userId + ", name = " + name, Toast.LENGTH_SHORT).show(); } } public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); final MainFragment f = new MainFragmentBuilder("Android", 100) .build(); // 以下のメソッドも生成される // final MainFragment f = MainFragmentBuilder.newMainFragment("Android", 100); getSupportFragmentManager() .beginTransaction() .replace(android.R.id.content, f) .commit(); } }

Library Project での使い方

Library Project の build.gradle apply plugin: 'com.android.library' // apt を使うので以下が必要 apply plugin: 'com.neenbedankt.android-apt' // Library Project では以下を追加しないとコンパイルエラーになる apt { arguments { fragmentArgsLib true } } android { ... } dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) // 以下が必要 compile 'com.hannesdorfmann.fragmentargs:annotation:2.1.2' apt 'com.hannesdorfmann.fragmentargs:processor:2.1.2' } 2015年9月7日時点の FragmentArgs の README だと fragmentArgsLib = true のように = がついてますが、android-apt:1.7 ではメソッドになったので = が必要ないです。

public class MainLibraryFragment extends Fragment { @Arg int userId; @Arg String name; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // Library Project のときは生成されたビルダークラスの injectArguments() を利用する必要がある MainLibraryFragmentBuilder.injectArguments(this); } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); Toast.makeText(getActivity(), "userId = " + userId + ", name = " + name, Toast.LENGTH_SHORT).show(); } }