2013年12月19日木曜日

Sublime Text 2 のプラグインで Toggle Comment を実装する

1. AAAPackageDev を Sublime Text に入れる
  • https://bitbucket.org/guillermooo/aaapackagedev/downloads から AAAPackageDev.sublime-package をダウンロードする
  • AAAPackageDev.sublime-package を Sublime Text の Installed Packages ([Preferences] - [Browse Packages...] から開くフォルダの一つ上の階層にある)に入れる
  • Sublime Text を再起動する


2. Package を作成する

作成する Syntax Definition に対応するパッケージがない場合は

[Tools] - [Packages] - [Package Development] - [New Packages...]

を選択し、パッケージ名を入力して Enter を押す



3. Comments Definition を作成する
  • <lang_name>.JSON-tmPreferences というファイル名で Packages/User フォルダーか、対応するパッケージフォルダに保存する { "name": "Comments", "scope": "source.ts", "settings": { "shellVariables" : [ { "name": "TM_COMMENT_START", "value": "// " }, { "name": "TM_COMMENT_START_2", "value": "/*" }, { "name": "TM_COMMENT_END_2", "value": "*/" } ] }, "uuid": "38232be9-44f1-49fd-91d4-85f5884fb298" }
  • "name" : "Comments" にする(別でもいいような気もするが)
  • "scope" : 対応する .tmLanguage の scopeName の値を使う
  • TM_COMMENT_START はシングルラインコメント、[Edit] - [Comment] - [Toggle Comment] に対応する
  • TM_COMMENT_START_2 はブロックコメントの開始、TM_COMMENT_END_2 はブロックコメントの終わり、[Edit] - [Comment] - [Toggle Block Comment] に対応する
  • ブロックコメントがない場合、TM_COMMENT_START_2とTM_COMMENT_END_2を両方省略することができる


4. .tmPreferences ファイルに変換する
  • [Tools] - [Build System] - [JSON to Property List] を選択
  • F7(または [Tools] - [Build])を押すと .JSON-tmPreferences ファイルと同じディレクトリに .tmPreferences ファイルができる
  • Sublime Text を再起動する




Sublime Text 2 用 ReVIEW プラグインでは [Edit] - [Comment] - [Toggle Comment] で先頭に #@# が挿入されるようにしました!



2013年12月9日月曜日

Android Javaコードで dp 単位を px 単位に変換する

1. DisplayMetrics を使う // 8dp に相当する px 値を取得 DisplayMetrics metrics = getResources().getDisplayMetrics(); int padding = (int) (metrics.density * 8);

2. TypedValue.applyDimension() を使う // 8dp に相当する px 値を取得 int padding = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8, getResources().getDisplayMetrics());

2013年12月2日月曜日

ActionBar の Overflow Menu の selector の色を変える

Android UI Cookbook for 4.0 で紹介した方法だと、適用する selector が不透明の場合はいいのですが、半透明の場合問題が起こります。

以下は、Android UI Cookbook for 4.0 で紹介した方法(android:itemBackground を指定する方法)で、押したとき背景が半透明の赤になるようにした場合です。



赤とデフォルトの水色が混ざって、紫っぽくなってしまっています。
これは、半透明の赤の下にデフォルトの水色の selector も表示されているからです。

これを修正する方法は2つあります。
  • 1. android:itemBackground には何も指定せず、下の水色の selector を変更する
  • 2. android:itemBackground を指定して、下の水色の selector に透明を指定する
いずれにしろ、この水色の selector を変更しないといけません。
結論としては、android:listChoiceBackgroundIndicator を利用します。この属性は API Level 11 からですが、Support Library v7 の appcompat でも対応しています。

res/values/styles.xml

ただし!なぜかベースのテーマを Theme.Holo.Light.DarkActionBar にすると、この設定が効きません!(Theme.Holo、Theme.Holo.Light は効く)
原因はまだ見つけてません。ぐぬぬ


2013.12.3 追記

原因&解決方法を見つけました!
Theme.Holo.Light.DarkActionBar でセットされている属性を一つずつ追加していったところ、 @android:style/Theme.Holo が原因だということがわかりました。Theme.Holo および Theme.Holo.Light ではこの属性には @null が指定されています。

この属性は ActionBarImpl クラスで利用されています。

http://tools.oesf.biz/android-4.4.0_r1.0/xref/frameworks/base/core/java/com/android/internal/app/ActionBarImpl.java#800 800 public Context getThemedContext() { 801 if (mThemedContext == null) { 802 TypedValue outValue = new TypedValue(); 803 Resources.Theme currentTheme = mContext.getTheme(); 804 currentTheme.resolveAttribute(com.android.internal.R.attr.actionBarWidgetTheme, 805 outValue, true); 806 final int targetThemeRes = outValue.resourceId; 807 808 if (targetThemeRes != 0 && mContext.getThemeResId() != targetThemeRes) { 809 mThemedContext = new ContextThemeWrapper(mContext, targetThemeRes); 810 } else { 811 mThemedContext = mContext; 812 } 813 } 814 return mThemedContext; 815 } そこで、Theme.Holo を継承し android:listChoiceBackgroundIndicator をセットしたテーマを別途用意し、android:actionBarWidgetTheme に指定するようにしたところうまくいきました。




■ 詳細解説

Overflow Menu は ListPopupWindow です。

http://tools.oesf.biz/android-4.4.0_r1.0/xref/frameworks/base/core/java/com/android/internal/view/menu/MenuPopupHelper.java#122 122 public boolean tryShow() { 123 mPopup = new ListPopupWindow(mContext, null, com.android.internal.R.attr.popupMenuStyle); 124 mPopup.setOnDismissListener(this); 125 mPopup.setOnItemClickListener(this); 126 mPopup.setAdapter(mAdapter); 127 mPopup.setModal(true); 128 129 View anchor = mAnchorView; 130 if (anchor != null) { 131 final boolean addGlobalListener = mTreeObserver == null; 132 mTreeObserver = anchor.getViewTreeObserver(); // Refresh to latest 133 if (addGlobalListener) mTreeObserver.addOnGlobalLayoutListener(this); 134 anchor.addOnAttachStateChangeListener(this); 135 mPopup.setAnchorView(anchor); 136 mPopup.setDropDownGravity(mDropDownGravity); 137 } else { 138 return false; 139 } 140 141 if (!mHasContentWidth) { 142 mContentWidth = measureContentWidth(); 143 mHasContentWidth = true; 144 } 145 146 mPopup.setContentWidth(mContentWidth); 147 mPopup.setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED); 148 mPopup.show(); 149 mPopup.getListView().setOnKeyListener(this); 150 return true; 151 } ListPopupWindow には setListSelector() というメソッドが用意されています。

http://tools.oesf.biz/android-4.4.0_r1.0/xref/frameworks/base/core/java/android/widget/ListPopupWindow.java 350 public void setListSelector(Drawable selector) { 351 mDropDownListHighlight = selector; 352 } ... 971 private int buildDropDown() { 1015 private int buildDropDown() { 1016 ViewGroup dropDownView; 1017 int otherHeights = 0; 1018 1019 if (mDropDownList == null) { 1020 Context context = mContext; 1021 1022 /** 1023 * This Runnable exists for the sole purpose of checking if the view layout has got 1024 * completed and if so call showDropDown to display the drop down. This is used to show 1025 * the drop down as soon as possible after user opens up the search dialog, without 1026 * waiting for the normal UI pipeline to do it's job which is slower than this method. 1027 */ 1028 mShowDropDownRunnable = new Runnable() { 1029 public void run() { 1030 // View layout should be all done before displaying the drop down. 1031 View view = getAnchorView(); 1032 if (view != null && view.getWindowToken() != null) { 1033 show(); 1034 } 1035 } 1036 }; 1037 1038 mDropDownList = new DropDownListView(context, !mModal); 1039 if (mDropDownListHighlight != null) { 1040 mDropDownList.setSelector(mDropDownListHighlight); 1041 } ... しかし、MenuPopupHelper では、setListSelector() を呼んでくれていません。

ListPopupWindow 内のリストは、DropDownListView です。 このクラスは ListPopupWindow の内部クラスです。

http://tools.oesf.biz/android-4.4.0_r1.0/xref/frameworks/base/core/java/android/widget/ListPopupWindow.java#1445 1445 public DropDownListView(Context context, boolean hijackFocus) { 1446 super(context, null, com.android.internal.R.attr.dropDownListViewStyle); 1447 mHijackFocus = hijackFocus; 1448 // TODO: Add an API to control this 1449 setCacheColorHint(0); // Transparent, since the background drawable could be anything. 1450 } ここでは com.android.internal.R.attr.dropDownListViewStyle を defStyleAttr として渡しています。

よって、android:dropDownListViewStyle を指定すればいいということです。Holo テーマでどのようなスタイルになっているか見てみましょう。

http://tools.oesf.biz/android-4.4.0_r1.0/xref/frameworks/base/core/res/res/values/themes.xml 906 1221 Theme.Holo も Theme.Holo.Light も @android:style/Widget.Holo.ListView.DropDown がセットされています。

Widget.Holo.ListView.DropDown は Widget.Holo.ListView と同じで、Widget.Holo.ListView では android:divider として ?android:attr/listDivider を、android:listSelector として ?android:attr/listChoiceBackgroundIndicator をセットしています。 1649 1715 よって、android:listChoiceBackgroundIndicator に変更したい selector を指定すればいいということになります。



2013年12月1日日曜日

Espresso で Navigation Drawer を開閉する(DIを使わない編)

Navigation Drawer を開閉するには android.R.id.home ボタンをタップすればいいので、 public class MainActivityTest extends ActivityInstrumentationTestCase2<MainActivity> { public MainActivityTest() { super(MainActivity.class); } @Override public void setUp() throws Exception { super.setUp(); // Espresso will not launch our activity for us, we must launch it via // getActivity(). getActivity(); } public void testDrawerOpen() { // Drawer open onView(withId(android.R.id.home)).perform(click()); // Click Settings onView(withId(R.id.settings)).perform(click()); } } で OK そうです(R.id.settings は Navigation Drawer 内のボタン)。

ところが、これはテストに失敗します。
onView(withId(R.id.settings)).perform(click());
のところで、そんな Id の View は見つからないと言われてしまいます。

原因は、Navigation Drawer が開き終わる前に View を探そうとするからです。

そこで、Navigation Drawer が開き(または閉じ)はじめてから、閉じる(または開く)まで、 Espresso に今は Idle 状態じゃないと伝えるようにします。

まず、IdlingResource インタフェースを実装したクラスのインスタンスを用意し、Espresso.registerIdlingResources()で登録します。

IdlingResource を実装したクラスとして、CountingIdlingResource が用意されています。
このクラスは内部でカウンターを持っていて、increment() と decrement() でカウンターの値を変え、カウンターが 0 のときが Idle 状態として Espresso に伝えられます。

Navigation Drawer を実現している DrawerLayout クラスの DrawerListener.onDrawerStateChanged() を利用して、カウンターの値を変えるようにします。

そのため、テスト対象の Activity に口を用意しないといけません。 public class MainActivity extends ActionBarActivity implements DrawerListener { private DrawerLayout mDrawerLayout; private View mDrawerContainer; private ActionBarDrawerToggle mDrawerToggle; private DrawerFragment mDrawerFragment; /** * Espresso で Drawer を開くため */ public interface DrawerStateListener { public void onDrawerStateChanged(int newState); } private DrawerStateListener mDrawerStateListener; public void setDrawerListener(DrawerStateListener l) { mDrawerStateListener = l; } public DrawerStateListener getDrawerListener() { return mDrawerStateListener; } /** * */ @Override protected void onCreate(Bundle savedInstance) { super.onCreate(savedInstance); setContentView(R.layout.activity_main); mDrawerFragment = (DrawerFragment) getSupportFragmentManager().findFragmentById(R.id.fragment_drawer); mDrawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout); mDrawerContainer = findViewById(R.id.left_drawer); mDrawerToggle = new ActionBarDrawerToggle(this, mDrawerLayout, R.drawable.ic_drawer, R.string.drawer_open, R.string.drawer_close) { @Override public void onDrawerClosed(View drawerView) { supportInvalidateOptionsMenu(); super.onDrawerClosed(drawerView); } @Override public void onDrawerOpened(View drawerView) { supportInvalidateOptionsMenu(); super.onDrawerOpened(drawerView); } @Override public void onDrawerStateChanged(int newState) { if (mDrawerStateListener != null) { mDrawerStateListener.onDrawerStateChanged(newState); } super.onDrawerStateChanged(newState); } }; mDrawerLayout.setDrawerListener(mDrawerToggle); getSupportActionBar().setDisplayHomeAsUpEnabled(true); getSupportActionBar().setHomeButtonEnabled(true); } ... } Activity に DrawerStateListener という口を用意しました。
この DrawerStateListener を実装した DrawerStateListenerImpl クラスを用意し、onDrawerStateChanged(int newState) で newState の値に応じて increment(), decrement() します。 このテストでは Navigation Drawer 部分をドラッグしないので DrawerLayout.STATE_DRAGGING は無いと思って簡略化しています。

setUp() の中で getActivity() で取得した Activity に対して DrawerStateListenerImpl のインスタンスを差し込み、registerIdlingResources() で DrawerStateListenerImpl で参照している CountingIdlingResource のインスタンスを登録しています。 public class MainActivityTest extends ActivityInstrumentationTestCase2<MainActivity> { public MainActivityTest() { super(MainActivity.class); } private class DrawerStateListenerImpl implements DrawerStateListener { private final DrawerStateListener realDrawerListener; private final CountingIdlingResource mIdlingResource; private DrawerStateListenerImpl(DrawerStateListener l, CountingIdlingResource idlingResource) { this.realDrawerListener = l; this.mIdlingResource = checkNotNull(idlingResource); } @Override public void onDrawerStateChanged(int newState) { // ドラッグしないので if (newState != DrawerLayout.STATE_IDLE) { mIdlingResource.increment(); } else { mIdlingResource.decrement(); } if (realDrawerListener != null) { realDrawerListener.onDrawerStateChanged(newState); } } } @Override public void setUp() throws Exception { super.setUp(); // Espresso will not launch our activity for us, we must launch it via // getActivity(). MainActivity activity = getActivity(); CountingIdlingResource countingResource = new CountingIdlingResource("DrawerCalls"); activity.setDrawerListener(new DrawerStateListenerImpl(activity.getDrawerListener(), countingResource)); registerIdlingResources(countingResource); } public void testDrawerOpen() { // Drawer open onView(withId(android.R.id.home)).perform(click()); // Click Settings onView(withId(R.id.settings)).perform(click()); } } このテストでは、ちゃんと R.id.settings ボタンがクリックされます。


Unable to execute dex: java.nio.BufferOverflowException.

[2013-12-01 12:14:18 - Dex Loader] Unable to execute dex: java.nio.BufferOverflowException. Check the Eclipse log for stack trace.

とか言われて調べていると、次の Issue に行き当たりました。

Issue 61710 - android - java.nio.BufferOverflowException When Building with 19 Build Tools - Android Open Source Project - Issue Tracker - Google Project Hosting:

Build Tool を 19.0.0 から 18.1.1 にしたらいいらしので、

Android SDK Manager を開いて
  • Android SDK Build-tools Rev. 19 を削除
  • Android SDK Build-tools Rev. 18.1.1 をインストール


エラーがでなくなりました!



2013年11月26日火曜日

Espresso で EditTextPreference に文字を入力する方法

Espresso で EditText に日本語を入力する方法」と「Espresso で Preference をクリックさせる」の応用です。

EditTextPreference はダイアログ内の EditText をコードから生成し、Id として com.android.internal.R.id.edit をセットしています。

http://tools.oesf.biz/android-4.4.0_r1.0/xref/frameworks/base/core/java/android/preference/EditTextPreference.java#53 45 public class EditTextPreference extends DialogPreference { 46 /** 47 * The edit text shown in the dialog. 48 */ 49 private EditText mEditText; 50 51 private String mText; 52 53 public EditTextPreference(Context context, AttributeSet attrs, int defStyle) { 54 super(context, attrs, defStyle); 55 56 mEditText = new EditText(context, attrs); 57 58 // Give it an ID so it can be saved/restored 59 mEditText.setId(com.android.internal.R.id.edit); 60 61 /* 62 * The preference framework and view framework both have an 'enabled' 63 * attribute. Most likely, the 'enabled' specified in this XML is for 64 * the preference framework, but it was also given to the view framework. 65 * We reset the enabled state. 66 */ 67 mEditText.setEnabled(true); 68 } internal なので Id を指定する方法は使えません。そこで、isAssignableFrom()を使います。(withClassName()でも実現できます) public void testEditTextPreference() { // EditTextPreference をクリック onData(withPreferenceKey("edit_text_pref1")).perform(click()); onView(isAssignableFrom(EditText.class)).perform(clearText(), new InputTextAction("エスプレッソよりカフェラテ派")); onView(withText("OK")).perform(click()); // 例えば入力値が summary に表示されるような実装になっているなら、それをチェックできる onData(withPreferenceKey("edit_text_pref1")) .onChildView(withId(android.R.id.summary)) .check(matches(withText("エスプレッソよりカフェラテ派"))); }


Espresso で EditText に日本語を入力する方法

Espresso には、テキストをタイプする ViewActions.typeText() というメソッドが用意されています。
このメソッドは、引数で渡されたテキストの各文字に対応する KeyCode を入力するものです。
そのため、typeText("日本語") としても"日本語"は入力されませんし、ソフトキーボードが日本語キーボードのときに typeText("andoroido") とすると、"あんどろいど" と入力されます。
また困ったことに、ソフトキーボードが日本語キーボードのときに typeText("12345") とすると、全角で入力されます。orz


日本語を入力するには、setText() で直接セットするしかありません。
そのための ViewAction を実装したクラスを用意しました。

public void testInputJapanese() { onView(R.id.editText1).perform(clearText(), new InputTextAction("日本語")); onView(R.id.editText1).check(matches(withText("日本語"))); } public final class InputTextAction implements ViewAction { private final String mText; public InputTextAction(String text) { checkNotNull(text); mText = text; } @SuppressWarnings("unchecked") @Override public Matcher getConstraints() { return allOf(isDisplayed(), isAssignableFrom(EditText.class)); } @Override public void perform(UiController uiController, View view) { ((EditText) view).setText(mText); } @Override public String getDescription() { return "set text"; } }


ちなみに typeText() の実体は TypeTextAction クラスです。

ViewActions.java public final class ViewActions { ... public static ViewAction typeText(String stringToBeTyped) { return new TypeTextAction(stringToBeTyped); } } こちらでは UiController の injectString() を利用しています。また、SearchView にも入力できるみたいです。

TypeTextAction.java public final class TypeTextAction implements ViewAction { private static final String TAG = TypeTextAction.class.getSimpleName(); private final String stringToBeTyped; /** * Constructs {@link TypeTextAction} with given string. If the string is empty it results in no-op * (nothing is typed). * * @param stringToBeTyped String To be typed by {@link TypeTextAction} */ public TypeTextAction(String stringToBeTyped) { checkNotNull(stringToBeTyped); this.stringToBeTyped = stringToBeTyped; } @SuppressWarnings("unchecked") @Override public Matcher getConstraints() { Matcher matchers = allOf(isDisplayed()); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) { return allOf(matchers, supportsInputMethods()); } else { // SearchView does not support input methods itself (rather it delegates to an internal text // view for input). return allOf(matchers, anyOf(supportsInputMethods(), isAssignableFrom(SearchView.class))); } } @Override public void perform(UiController uiController, View view) { // No-op if string is empty. if (stringToBeTyped.length() == 0) { Log.w(TAG, "Supplied string is empty resulting in no-op (nothing is typed)."); return; } // Perform a click. new GeneralClickAction(Tap.SINGLE, GeneralLocation.CENTER, Press.PINPOINT) .perform(uiController, view); uiController.loopMainThreadUntilIdle(); try { if (!uiController.injectString(stringToBeTyped)) { Log.e(TAG, "Failed to type text: " + stringToBeTyped); throw new PerformException.Builder() .withActionDescription(this.getDescription()) .withViewDescription(HumanReadables.describe(view)) .withCause(new RuntimeException("Failed to type text: " + stringToBeTyped)) .build(); } } catch (InjectEventSecurityException e) { Log.e(TAG, "Failed to type text: " + stringToBeTyped); throw new PerformException.Builder() .withActionDescription(this.getDescription()) .withViewDescription(HumanReadables.describe(view)) .withCause(e) .build(); } } @Override public String getDescription() { return "type text"; } } UiController はインタフェースで、実装クラスは UiControllerImpl.java です。

UiControllerImpl.java @Override public boolean injectString(String str) throws InjectEventSecurityException { checkNotNull(str); checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!"); initialize(); // No-op if string is empty. if (str.length() == 0) { Log.w(TAG, "Supplied string is empty resulting in no-op (nothing is typed)."); return true; } boolean eventInjected = false; KeyCharacterMap keyCharacterMap = getKeyCharacterMap(); // TODO(user): Investigate why not use (as suggested in javadoc of keyCharacterMap.getEvents): // http://developer.android.com/reference/android/view/KeyEvent.html#KeyEvent(long, // java.lang.String, int, int) KeyEvent[] events = keyCharacterMap.getEvents(str.toCharArray()); checkNotNull(events, "Failed to get events for string " + str); Log.d(TAG, String.format("Injecting string: \"%s\"", str)); for (KeyEvent event : events) { checkNotNull(event, String.format("Failed to get event for character (%c) with key code (%s)", event.getKeyCode(), event.getUnicodeChar())); eventInjected = false; for (int attempts = 0; !eventInjected && attempts < 4; attempts++) { attempts++; // We have to change the time of an event before injecting it because // all KeyEvents returned by KeyCharacterMap.getEvents() have the same // time stamp and the system rejects too old events. Hence, it is // possible for an event to become stale before it is injected if it // takes too long to inject the preceding ones. event = KeyEvent.changeTimeRepeat(event, SystemClock.uptimeMillis(), 0); eventInjected = injectKeyEvent(event); } if (!eventInjected) { Log.e(TAG, String.format("Failed to inject event for character (%c) with key code (%s)", event.getUnicodeChar(), event.getKeyCode())); break; } } return eventInjected; }


Espresso で Preference をクリックさせる

Matcher 書かないとダメっぽかったです。
PreferenceMatcher が用意されていたので利用しました。
import static com.google.common.base.Preconditions.checkNotNull; ... public class EspressoTest extends ActivityInstrumentationTestCase2<MainPreferenceActivity> { public EspressoTest() { super(MainPreferenceActivity.class); } @Override public void setUp() throws Exception { super.setUp(); // Espresso will not launch our activity for us, we must launch it via // getActivity(). getActivity(); } // Preference のキーを指定して、対応するビューをクリックする public void testPreference() { onData(withPreferenceKey("pref-key")).perform(click()); } public static Matcher<Object> withPreferenceKey(final Matcher<Preference> preferenceMatcher) { checkNotNull(preferenceMatcher); return new BoundedMatcher<Object, Preference>(Preference.class) { @Override public void describeTo(Description description) { description.appendText("with preference key: "); preferenceMatcher.describeTo(description); } @Override protected boolean matchesSafely(Preference pref) { return preferenceMatcher.matches(pref); } }; } public static Matcher<Object> withPreferenceKey(String expectedText) { checkNotNull(expectedText); return withPreferenceKey(PreferenceMatchers.withKey(expectedText)); } }

応用で、Preference をクリック → なんかする → summary が適切な値になっていることをチェック public void testPreference() { // Preference をクリック onData(withPreferenceKey("pref-key")).perform(click()); // クリック先でごにょごにょ // summary が適切な値になっていることをチェック // summary の Id が android.R.id.summary であることを利用 onData(withPreferenceKey("pref-key")) .onChildView(withId(android.R.id.summary)) .check(matches(withText("correct summary value"))); }


2013年11月21日木曜日

Android で mockito を使う : すでにあるインスタンスから mock を作る

忘れるので、メモ。

Mockito.spy() を使う。

mockito と Espresso を組み合わせて使う場合、ActivityInstrumentationTestCase2 の setActivity() を Override してモック化した Activity を super.setActivity() に渡すようにしても、UIの操作が実際に行われるのはモック化前の生のActivityインスタンスに対してでした。 なので、ActivityInstrumentationTestCase2 の対象の Activity のメソッド呼び出しを mockito で検証するのは無理っぽいです。。。残念。。。


Android UI Testing framework の Espresso を使う

とりあえず、android-test-kit : Espresso の動画を見ましょう。

以下では Eclipse での設定方法を紹介します。
Android Studio での設定方法は Espresso のプロジェクトページ(上記のリンク)にあるので読んでください。

1. Developer options の設定

アニメーションを Off にしましょう。

設定(Settings) → 開発者向けオプション(Developer options)→
以下の3つを全て「アニメーションオフ(Animation off)」にする
  • ウィンドウアニメスケール (Window animation scale)
  • トランジションアニメスケール(Transition animation scale)
  • Animator再生時間スケール(Animator duration scale)


コードからやる方法


2. Espresso をテストプロジェクトに追加する

Espresso には、依存ライブラリとかも含めて1つの jar になっている standalone 版と、依存ライブラリが別になっている dependencies 版があります。

mockito と一緒に使う場合は、hamcrest がかぶってエラーになるので、dependencies 版を使います。

standalone 版を使う場合:git clone するなり、zip をダウンロードするなりして、 espresso-1.0-SNAPSHOT-bundled.jar を取得して、テストプロジェクトの libs フォルダに追加します。

dependencies 版を使う場合: dependencies 版 にある jar を全部 libs フォルダに入れます。
mockito (mockito-all-1.9.5.jar) と一緒に使う場合は、hamcrest-core-1.1.jar と hamcrest-integration-1.1.jar は libs に入れないでください。



テストプロジェクトの AndroidManifest.xml に <instrumentation android:name="com.google.android.apps.common.testing.testrunner.GoogleInstrumentationTestRunner" android:targetPackage="$YOUR_TARGET_APP_PACKAGE"/> を追加します。 AndroidManifest.xml の例 <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.espresso.test" android:versionCode="1" android:versionName="1.0" > <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="19" /> <application> <uses-library android:name="android.test.runner" /> </application> <instrumentation android:name="com.google.android.apps.common.testing.testrunner.GoogleInstrumentationTestRunner" android:targetPackage="com.example.espresso" /> </manifest> ウィザードから Android Test Project を作ると、android.test.InstrumentationTestRunner の instrumentation タグが作られますが、消しても大丈夫です。 <instrumentation android:name="android.test.InstrumentationTestRunner" android:targetPackage="com.example.espresso" />

GoogleInstrumentationTestRunner を介してテストが走るように Eclipse を設定します。
Eclipse の [Run] - [Run Configurations...]を選択



Run all tests in the selected project, or package にチェックして、 Instrumetation runner: に com.google.android.apps.common.testing.testrunner.GoogleInstrumentationTestRunner を選択して Apply をクリックします。



* クラス単体を対象とした場合(Run a single Test をチェック)、Instrumetation runner: に com.google.android.apps.common.testing.testrunner.GoogleInstrumentationTestRunner を選択すると、 The instrumentation runner must be of type android.test.InstrumentationTestRunner とエラーが出て怒られます。
Espresso ではクラス単体でテストを走らせることはできないってことなのかしら?

Espresso はいくつかの解析データを収集しています。
収集されたくない場合は、disableAnalytics という引数に true を指定して GoogleInstrumentationTestRunner に渡すことでオプトアウトすることができるとドキュメントには書いてあるのですが、方法がよくわかりませんでした。。。


3. Espresso を使う

例として、ログイン画面(MainActivity)でIDとパスワードを入力してボタンを押すと、Welcome画面(MainActivity2)に遷移するアプリを用意しました。 public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); final EditText idView = (EditText) findViewById(R.id.editText1); final EditText passView = (EditText) findViewById(R.id.editText2); final TextView statusView = (TextView) findViewById(R.id.textView3); findViewById(R.id.button1).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { statusView.setText(""); String id = idView.getText().toString(); if (TextUtils.isEmpty(id)) { statusView.setText("IDが入力されていません"); return; } String pass = passView.getText().toString(); if (TextUtils.isEmpty(pass)) { statusView.setText("Passwordが入力されていません"); return; } if (check(id, pass)) { Intent intent = new Intent(MainActivity.this, MainActivity2.class); intent.putExtra("id", id); startActivity(intent); } else { statusView.setText("IDとPasswordの組み合わせが違います"); } } }); } boolean check(String id, String pass) { // dummy return true; }; } ログイン画面(MainActivity)では、IDやパスワードが空の場合はステータス用のTextViewにメッセージが表示されます。
つまり
・ID入力用のEditTExtが空のときにステータス用のTextViewにメッセージが表示されるか
・Password入力用のEditTextが空のときにステータス用のTextViewにメッセージが表示されるか
をテストできます。



ログインできる場合は、Intentのextraにidを入れて、Welcome画面(MainActivity2)を開いています。 public class MainActivity2 extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main2); String id = getIntent().getStringExtra("id"); TextView tv = (TextView) findViewById(R.id.textView1); tv.setText("ようこそ" + id + "さん"); } } Welcome画面(MainActivity2)では、渡されたidをTextView に表示しています。
ここでは、
・ログイン画面で入力されたIDがWelcome画面に表示されるか
をテストできます。




では、テストクラスを作っていきます。

Espresso, ViewActions, ViewMatchers, ViewAssertions, Matchers などの主要 static メソッドを import static で定義しておきましょう。
Espresso のドキュメントに載っているサンプルコードはみな import static した後のコードです。そのことを知ってないとコードをみてもよくわからないでしょう。
ドキュメントのコードをコピペするときにも不便なので、以下の import static をテストクラスにコピペしておきましょう。

import static com.google.android.apps.common.testing.ui.espresso.Espresso.onData; import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; import static com.google.android.apps.common.testing.ui.espresso.Espresso.pressBack; import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.closeSoftKeyboard; import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.typeText; import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is;

Espresso は Activity を起動してくれないので、setUp() で getActivity() を呼んで Activity を起動する必要があります。

package com.example.espresso; import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.typeText; import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches; import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId; import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withText; import android.test.ActivityInstrumentationTestCase2; public class MainActivityTest extends ActivityInstrumentationTestCase2<MainActivity> { public MainActivityTest() { super(MainActivity.class); } @Override public void setUp() throws Exception { super.setUp(); // Espresso will not launch our activity for us, we must launch it via // getActivity(). getActivity(); } public void testEmptyId() { // IDを空のままログインボタンをクリック onView(withId(R.id.button1)).perform(click()); // ステータス用のTextViewにメッセージが表示されるかチェック onView(withId(R.id.textView3)).check(matches(withText("IDが入力されていません"))); } public void testEmptyPassword() { // IDを入力 onView(withId(R.id.editText1)).perform(typeText("yanzm")); // Passwordを空のままログインボタンをクリック onView(withId(R.id.button1)).perform(click()); // ステータス用のTextViewにメッセージが表示されるかチェック onView(withId(R.id.textView3)).check(matches(withText("Passwordが入力されていません"))); } public void testLogin() { // IDを入力 onView(withId(R.id.editText1)).perform(typeText("yanzm")); // Passwordを入力 onView(withId(R.id.editText2)).perform(typeText("1234567890")); // ログインボタンをクリック onView(withId(R.id.button1)).perform(click()); // Welcome画面に表示されるかチェック onView(withId(R.id.textView1)).check(matches(withText("ようこそyanzmさん"))); } }

こんな感じです。

他にも、ListView や Spinner などの特定の行の View を指定するために使う onData() などがあります。


参考




2013年11月20日水曜日

Device Art Generator ではめ込み画像を作る

Android Developers に Device Art Generator という便利なものがあることに最近気づきました。



デイバス名の下にはめ込む画像のピクセルサイズが書かれています。
このサイズの画像をデバイス画像のところにドラッグ&ドロップします。
(サイズの違う画像だと怒られます)

・影
・画面の光沢

の有り無しを設定できるほか、回転させることもできます。

影あり・画面光沢あり


影なし・画面光沢あり


影なし・画面光沢なし


回転





2013年11月19日火曜日

Android Facebook SDK で share する

Facebook アプリは ACTION_SEND を受けとるくせに、Facebook に投稿してくれません。
ひどいです。ちゃんと処理しないなら ACTION_SEND 受け取らないでほしいです。。。

Facebook に投稿したければ Facebook SDK 使えよ、ということだそうです。
でもドキュメントがわちゃーでわかりにくかったので、自分ためにメモっておきます。



1. Facebook Apps を作る

https://developers.facebook.com/apps


右上の + Create New App から





2. Debug key の key hash を登録する

Debug key のパスワードは android
$[ keytool -exportcert -alias androiddebugkey -keystore ~/.android/debug.keystore | openssl sha1 -binary | openssl base64
Enter keystore password:  android
pnw+gKvPF3Y+pP9nbguTOPw3s1g=




3. Facebook SDK for Android をダウンロードして展開する

https://developers.facebook.com/docs/android/

現在は v3.5.2





4. facebook-android-sdk-3.5.2/facebook/ を指定してインポートする



FacebookSDK の libs に含まれる android-support-v4.jar が古いので、新しいので上書きする。



5. 1.で指定したパッケージ名のアプリと Activity を作る





6. App ID を AndroidManifest.xml に宣言する

res/values/strings.xml APP_ID AndroidManifest.xml <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" ... > ... <application ... > ... <meta-data android:value="@string/app_id" android:name="com.facebook.sdk.ApplicationId"/> </application> </manifest>

7. FacebookSDK のライブラリプロジェクトを追加する





8. Activity に Share を実装する

ShareDialogBuilder を使う public class MainActivity extends Activity { private UiLifecycleHelper uiHelper; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); uiHelper = new UiLifecycleHelper(this, new Session.StatusCallback() { @Override public void call(Session session, SessionState state, Exception exception) { Log.i("Activity", "SessionState : " + state); } }); uiHelper.onCreate(savedInstanceState); findViewById(R.id.button1).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { share(); } }); } private void share() { if (FacebookDialog.canPresentShareDialog(getApplicationContext(), FacebookDialog.ShareDialogFeature.SHARE_DIALOG)) { try { String name = "名前"; String url = "http://developer.android.com"; String description = "詳細"; // Fragment で発行するときは setFragment() を呼ぶ FacebookDialog shareDialog = new FacebookDialog.ShareDialogBuilder(this).setDescription(description) .setName(name).setLink(url).build(); uiHelper.trackPendingDialogCall(shareDialog.present()); } catch (FacebookException e) { Toast.makeText(this, "Facebook でエラーが発生しました。", Toast.LENGTH_SHORT).show(); } } } @Override protected void onResume() { super.onResume(); uiHelper.onResume(); } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); uiHelper.onSaveInstanceState(outState); } @Override public void onPause() { super.onPause(); uiHelper.onPause(); } @Override public void onDestroy() { super.onDestroy(); uiHelper.onDestroy(); } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); uiHelper.onActivityResult(requestCode, resultCode, data, new FacebookDialog.Callback() { @Override public void onError(FacebookDialog.PendingCall pendingCall, Exception error, Bundle data) { Log.e("Activity", String.format("Error: %s", error.toString())); } @Override public void onComplete(FacebookDialog.PendingCall pendingCall, Bundle data) { Log.i("Activity", "Success!"); } }); } }



9. Release Key の key hash を登録する

keytool -exportcert -alias <RELEASE_KEY_ALIAS> -keystore <RELEASE_KEY_PATH> | openssl sha1 -binary | openssl base64


10. Facebook Apps の設定の Sandbox Mode を Disabled にする







参考



Android Volley の NetworkImageView で Bitmap の最大サイズを指定する

Volley の NetworkImageView は便利なのですが、Bitmap のサイズを最適化してくれません。

NetworkImageView で画像のダウンロードを開始するのが loadImageIfNecessary() です。

https://android.googlesource.com/platform/frameworks/volley/+/master/src/com/android/volley/toolbox/NetworkImageView.java public class NetworkImageView extends ImageView { ... /** Local copy of the ImageLoader. */ private ImageLoader mImageLoader; ... public void setImageUrl(String url, ImageLoader imageLoader) { mUrl = url; mImageLoader = imageLoader; // The URL has potentially changed. See if we need to load it. loadImageIfNecessary(false); } ... /** * Loads the image for the view if it isn't already loaded. * @param isInLayoutPass True if this was invoked from a layout pass, false otherwise. */ private void loadImageIfNecessary(final boolean isInLayoutPass) { ... // The pre-existing content of this view didn't match the current URL. Load the new image // from the network. ImageContainer newContainer = mImageLoader.get(mUrl, new ImageListener() { @Override public void onErrorResponse(VolleyError error) { if (mErrorImageId != 0) { setImageResource(mErrorImageId); } } @Override public void onResponse(final ImageContainer response, boolean isImmediate) { // If this was an immediate response that was delivered inside of a layout // pass do not set the image immediately as it will trigger a requestLayout // inside of a layout. Instead, defer setting the image by posting back to // the main thread. if (isImmediate && isInLayoutPass) { post(new Runnable() { @Override public void run() { onResponse(response, false); } }); return; } if (response.getBitmap() != null) { setImageBitmap(response.getBitmap()); } else if (mDefaultImageId != 0) { setImageResource(mDefaultImageId); } } }); // update the ImageContainer to be the new bitmap container. mImageContainer = newContainer; } ... } ここで ImageLoader の get(url, imageListener) を呼んでいます。
ImageLoader には引数が4つの get(url, imageLoader, maxWidth, maxHeight) もあり、引数が2つの get() を呼んだ場合は、maxWidth, maxHeight には 0 が渡され、生成される Bitmap は実際の画像サイズになります。 public class ImageLoader { ... public ImageContainer get(String requestUrl, final ImageListener listener) { return get(requestUrl, listener, 0, 0); } public ImageContainer get(String requestUrl, ImageListener imageListener, int maxWidth, int maxHeight) { // only fulfill requests that were initiated from the main thread. throwIfNotOnMainThread(); final String cacheKey = getCacheKey(requestUrl, maxWidth, maxHeight); // Try to look up the request in the cache of remote images. Bitmap cachedBitmap = mCache.getBitmap(cacheKey); if (cachedBitmap != null) { // Return the cached bitmap. ImageContainer container = new ImageContainer(cachedBitmap, requestUrl, null, null); imageListener.onResponse(container, true); return container; } // The bitmap did not exist in the cache, fetch it! ImageContainer imageContainer = new ImageContainer(null, requestUrl, cacheKey, imageListener); // Update the caller to let them know that they should use the default bitmap. imageListener.onResponse(imageContainer, true); // Check to see if a request is already in-flight. BatchedImageRequest request = mInFlightRequests.get(cacheKey); if (request != null) { // If it is, add this request to the list of listeners. request.addContainer(imageContainer); return imageContainer; } // The request is not already in flight. Send the new request to the network and // track it. Request<?> newRequest = new ImageRequest(requestUrl, new Listener<Bitmap>() { @Override public void onResponse(Bitmap response) { onGetImageSuccess(cacheKey, response); } }, maxWidth, maxHeight, Config.RGB_565, new ErrorListener() { @Override public void onErrorResponse(VolleyError error) { onGetImageError(cacheKey, error); } }); mRequestQueue.add(newRequest); mInFlightRequests.put(cacheKey, new BatchedImageRequest(newRequest, imageContainer)); return imageContainer; } } maxWidth と maxHeight は ImageRequest のコンストラクタに渡されています。 public class ImageRequest extends Request<Bitmap> { ... private final int mMaxWidth; private final int mMaxHeight; ... public ImageRequest(String url, Response.Listener<Bitmap> listener, int maxWidth, int maxHeight, Config decodeConfig, Response.ErrorListener errorListener) { super(Method.GET, url, errorListener); setRetryPolicy( new DefaultRetryPolicy(IMAGE_TIMEOUT_MS, IMAGE_MAX_RETRIES, IMAGE_BACKOFF_MULT)); mListener = listener; mDecodeConfig = decodeConfig; mMaxWidth = maxWidth; mMaxHeight = maxHeight; } ... /** * The real guts of parseNetworkResponse. Broken out for readability. */ private Response<Bitmap> doParse(NetworkResponse response) { byte[] data = response.data; BitmapFactory.Options decodeOptions = new BitmapFactory.Options(); Bitmap bitmap = null; if (mMaxWidth == 0 && mMaxHeight == 0) { decodeOptions.inPreferredConfig = mDecodeConfig; bitmap = BitmapFactory.decodeByteArray(data, 0, data.length, decodeOptions); } else { // If we have to resize this image, first get the natural bounds. decodeOptions.inJustDecodeBounds = true; BitmapFactory.decodeByteArray(data, 0, data.length, decodeOptions); int actualWidth = decodeOptions.outWidth; int actualHeight = decodeOptions.outHeight; // Then compute the dimensions we would ideally like to decode to. int desiredWidth = getResizedDimension(mMaxWidth, mMaxHeight, actualWidth, actualHeight); int desiredHeight = getResizedDimension(mMaxHeight, mMaxWidth, actualHeight, actualWidth); // Decode to the nearest power of two scaling factor. decodeOptions.inJustDecodeBounds = false; // TODO(ficus): Do we need this or is it okay since API 8 doesn't support it? // decodeOptions.inPreferQualityOverSpeed = PREFER_QUALITY_OVER_SPEED; decodeOptions.inSampleSize = findBestSampleSize(actualWidth, actualHeight, desiredWidth, desiredHeight); Bitmap tempBitmap = BitmapFactory.decodeByteArray(data, 0, data.length, decodeOptions); // If necessary, scale down to the maximal acceptable size. if (tempBitmap != null && (tempBitmap.getWidth() > desiredWidth || tempBitmap.getHeight() > desiredHeight)) { bitmap = Bitmap.createScaledBitmap(tempBitmap, desiredWidth, desiredHeight, true); tempBitmap.recycle(); } else { bitmap = tempBitmap; } } if (bitmap == null) { return Response.error(new ParseError(response)); } else { return Response.success(bitmap, HttpHeaderParser.parseCacheHeaders(response)); } } } ImageRequest の doParse() で、mMaxWidth == 0 && mMaxHeight == 0 のときはバイト配列をそのまま Bitmap にしているのがわかりますね。
それ以外のときは BitmapFactory.Options の inJustDecodeBounds や inSampleSize を使って Bitmap をスケールしています。

以下では、View のサイズがわかっている場合はそのサイズを使い、わからないときは画面サイズを指定するようにしてみました。 public class NetworkImageView extends ImageView { ... private void loadImageIfNecessary(final boolean isInLayoutPass) { int width = getWidth(); int height = getHeight(); ... DisplayMetrics metrics = getResources().getDisplayMetrics(); int w = width > 0 ? width : metrics.widthPixels; int h = height > 0 ? height : metrics.heightPixels; // The pre-existing content of this view didn't match the current URL. Load the new image // from the network. ImageContainer newContainer = mImageLoader.get(mUrl, new ImageListener() { @Override public void onErrorResponse(VolleyError error) { if (mErrorImageId != 0) { setImageResource(mErrorImageId); } } @Override public void onResponse(final ImageContainer response, boolean isImmediate) { // If this was an immediate response that was delivered inside of a layout // pass do not set the image immediately as it will trigger a requestLayout // inside of a layout. Instead, defer setting the image by posting back to // the main thread. if (isImmediate && isInLayoutPass) { post(new Runnable() { @Override public void run() { onResponse(response, false); } }); return; } if (response.getBitmap() != null) { setImageBitmap(response.getBitmap()); } else if (mDefaultImageId != 0) { setImageResource(mDefaultImageId); } } }, w, h); // update the ImageContainer to be the new bitmap container. mImageContainer = newContainer; } }



Volley で大きい画像を処理してはいけない

Google I/O 2013 のセッションでも言われてましたね。

ネットワークのレスポンスは com.android.volley.toolbox.BasicNetwork の performRequest() で処理されて、entity は entityToBytes() で一旦バイト配列に格納されます。

https://android.googlesource.com/platform/frameworks/volley/+/master/src/com/android/volley/toolbox/BasicNetwork.java @Override public NetworkResponse performRequest(Request<?> request) throws VolleyError { ... // Some responses such as 204s do not have content. We must check. if (httpResponse.getEntity() != null) { responseContents = entityToBytes(httpResponse.getEntity()); } else { // Add 0 byte response as a way of honestly representing a // no-content request. responseContents = new byte[0]; } ... } ... /** Reads the contents of HttpEntity into a byte[]. */ private byte[] entityToBytes(HttpEntity entity) throws IOException, ServerError { PoolingByteArrayOutputStream bytes = new PoolingByteArrayOutputStream(mPool, (int) entity.getContentLength()); byte[] buffer = null; try { InputStream in = entity.getContent(); if (in == null) { throw new ServerError(); } buffer = mPool.getBuf(1024); int count; while ((count = in.read(buffer)) != -1) { bytes.write(buffer, 0, count); } return bytes.toByteArray(); } finally { try { // Close the InputStream and release the resources by "consuming the content". entity.consumeContent(); } catch (IOException e) { // This can happen if there was an exception above that left the entity in // an invalid state. VolleyLog.v("Error occured when calling consumingContent"); } mPool.returnBuf(buffer); bytes.close(); } } entityToBytes() では、PoolingByteArrayOutputStream の write() を呼んでいます。 https://android.googlesource.com/platform/frameworks/volley/+/master/src/com/android/volley/toolbox/PoolingByteArrayOutputStream.java public class PoolingByteArrayOutputStream extends ByteArrayOutputStream { ... /** * Ensures there is enough space in the buffer for the given number of additional bytes. */ private void expand(int i) { /* Can the buffer handle @i more bytes, if not expand it */ if (count + i <= buf.length) { return; } byte[] newbuf = mPool.getBuf((count + i) * 2); System.arraycopy(buf, 0, newbuf, 0, count); mPool.returnBuf(buf); buf = newbuf; } @Override public synchronized void write(byte[] buffer, int offset, int len) { expand(len); super.write(buffer, offset, len); } } PoolingByteArrayOutputStream の write() では、バッファのサイズが足りない場合 mPool.getBuf() で現在の2倍の配列を確保しようとします。

このように、(Bitmap化する際に縮小する場合でも)いったん元サイズのまま byte 配列に確保されるため、これを並列処理で行ったりすると OutOfMemory Error になることがあります(特に古いデバイスでは)。

Honeycomb (API Level 11) で AsyncTask の実行がシングルスレッドに戻ったのって、こういうメモリエラー回避のためなのかなとか思ったり思わなかったり。ちなみに AsyncTask は API Level 3 で追加されたのですが、追加されたときはシングルスレッドでの実行でした。スレッドプールによる並列処理になったのは Donut (API Level 4) からです。

「2.x のデバイス + Volley + 大きい画像 + AsyncTask」は危険!ということですね。



2013年11月18日月曜日

Android 7 inch 用にスケールアップする場合の文字サイズ

スマホのレイアウトをそのまま 7 inch で表示すると多少スカスカになります。
レイアウトを最適化するほどではない場合、全体的なサイズを大きくして調整することがよくあります。
こういうときは、だいたい1.5倍にするといい感じになります。

文字サイズの場合は、1.5倍にすると大きすぎるので、 デフォルトの文字サイズに対応する私なりの値をメモっておきます。

res/values/dimens.xml 22sp 18sp 14sp

res/values-sw600dp/dimens.xml 30sp 25sp 20sp

res/values/styles.xml




2013年11月14日木曜日

KitKat (Android 4.4) の UI について

Android 4.4 KitKat 冬コミ原稿リレーの 11/14 分です。

Android 4.4 KitKat の API の内、User Interface に関わる部分を取り上げます。


■ Immersive full-screen mode

昔々、2.x まではフルスクリーンモードというものがありました。このときはホームキーやバックキーがハードキー(ハードボタン)だったため、ステータスバーが隠れ、画面全体がアプリの領域になるというものでした。

3.x になると、画面下部がナビゲーションバーというものになり、ステータスバーの情報はナビゲーションバーの右側に、ホームキーやバックキーは左側に移動しました。 このナビゲーションバーは、これまでのフルスクリーンモードを指定しても表示されたままでした。

4.0 ICS (API Level 14) になると、スマホにもナビゲーションバーが導入されました。
ハードキーよ、さようなら。
ナビゲーションバーを暗くしたり、インタラクションがない間(動画を見てるなど)非表示にすることができるようになりました。
  • SYSTEM_UI_FLAG_VISIBLE (0x00000000)
  • SYSTEM_UI_FLAG_LOW_PROFILE (0x00000001)
    ナビゲーションバーを暗くする(オーバーフローメニューを表示するとなぜかクリアされる。Action Item のクリックではクリアされない)
  • SYSTEM_UI_FLAG_HIDE_NAVIGATION (0x00000002)
    インタラクションがない間ナビゲーションバーを非表示にする (ちなみに、SYSTEM_UI_FLAG_LOW_PROFILE と SYSTEM_UI_FLAG_HIDE_NAVIGATION を両方指定すると、ナビゲーションバーは非表示になり、インタラクションがあって表示された瞬間は暗くなっていて、すぐに明るくなる)

4.1 Jelly Bean (API Level 16) では、ナビゲーションバーやステータスバー(これらを合わせてシステムバーとドキュメントでは書かれている)の見た目を制御するためのフラグがいくつか追加されました。
  • SYSTEM_UI_FLAG_FULLSCREEN (0x00000004)
    ステータスバーを非表示にする
    SYSTEM_UI_FLAG_LOW_PROFILE や SYSTEM_UI_FLAG_HIDE_NAVIGATION と組み合わせて指定すると、ナビゲーションバーが表示されるタイミングでステータスバーも表示される
    単体で指定した場合はインタラクションがあっても非表示のまま
  • SYSTEM_UI_FLAG_LAYOUT_STABLE (0x00000100)
  • SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION (0x00000200)
    ナビゲーションバーが非表示であるかのようにビューをレイアウトする
  • SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN (0x00000400)
    ステータスバーが非表示であるかのようにビューをレイアウトする
SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN だけを指定した状態


4.4 KitKat (API Level 19) では、インタラクションがあってもシステムバーを非表示のままにできるようになりました。
  • SYSTEM_UI_FLAG_IMMERSIVE (0x00000800)
  • SYSTEM_UI_FLAG_IMMERSIVE_STICKY (0x00001000)
* immersive は没入とか没頭という意味です。

SYSTEM_UI_FLAG_HIDE_NAVIGATION フラグや SYSTEM_UI_FLAG_FULLSCREEN フラグと組み合わせて setSystemUiVisibility() に指定します。
これらを指定すると、ステータスバー(SYSTEM_UI_FLAG_FULLSCREEN)やナビゲーションバー(SYSTEM_UI_FLAG_HIDE_NAVIGATION)が非表示になり、画面全体をアプリの領域にできます。
システムバーを表示するには、「システムバーが表示されるべき領域から内側に向かってフリック」します。
ユーザーがこの操作を行うと、SYSTEM_UI_FLAG_HIDE_NAVIGATION フラグと SYSTEM_UI_FLAG_FULLSCREEN フラグがクリアされるので、全画面表示ではなくなります。
SYSTEM_UI_FLAG_IMMERSIVE を指定した場合は、そのまま全画面表示が解除されたままになり、SYSTEM_UI_FLAG_IMMERSIVE_STICKY を指定すると数秒後に再び全画面表示に戻ります。

なぜか、オーバーフローメニューを表示すると immersive mode がクリアされてしまいます。。。


SYSTEM_UI_FLAG_IMMERSIVE | SYSTEM_UI_FLAG_FULLSCREEN | SYSTEM_UI_FLAG_HIDE_NAVIGATION

わーい。全画面だー。

SYSTEM_UI_FLAG_IMMERSIVE_STICKY | SYSTEM_UI_FLAG_FULLSCREEN | SYSTEM_UI_FLAG_HIDE_NAVIGATION で「システムバーが表示されるべき領域から内側に向かってフリック」したとき

View の上に半透明で表示されます

初めて全画面表示したときは、こんなポップアップが自動で出ました。




■ Translucent system bars

システムバーを透明にすることができるようになりました。

システムバーが透明のテーマが用意されています。
  • Theme.Holo.NoActionBar.TranslucentDecor
  • Theme.Holo.Light.NoActionBar.TranslucentDecor
ActionBar とは併用できないんですかね。。。

Theme.Holo.NoActionBar.TranslucentDecor

黒わからん。。。w

Theme.Holo.Light.NoActionBar.TranslucentDecor
1592 ステータスバーを透明にする属性が android:windowTranslucentStatus
ナビゲーションバーを透明にする属性が android:windowTranslucentNavigation
です。

試しに ActionBar と併用してみました。 としたら、こうなりました。。。ひどいw


android:fitsSystemWindows="true" を指定すると、システムバー分のパディングがセットされます。 ただし、android:paddingXXX で指定したパディングが上書きされるので注意が必要。 ... 左右の padding もなくなってしまった。。。



■ Enhanced notification listener

API Level 18 で追加された NotificationListenerService が拡張されました。

Notification に新しく extras というフィールドが増えて、この Bundle 用のキーがいろいろ追加されました。 また、新しく actions というフィールドも増えました。このフィールドは Notification.Action の配列で、Notification.Builder の addAction() で格納されます。



■ Scenes and transitions

新しく android.transtion フレームワークが提供されるようになりました。

ユーザーインタフェースの異なる状態間のアニメーションを促進するためのものだそうです。 Scene.getSceneForLayout() を使って Scene を作ります。 レイアウトを切り替える領域の ViewGroup を第1引数に、切り替えるレイアウトを第2引数に、第3引数には Context を指定します。 あとは、TransitionManager.go() を呼べば、いい感じにアニメーションでレイアウトが切り替わります。 findViewById(R.id.button1).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { ViewGroup view = (ViewGroup) findViewById(android.R.id.content); Scene scene = Scene.getSceneForLayout(view, R.layout.scene2, MainActivity.this); TransitionManager.go(scene); } }); レイアウトを切り替える領域の ViewGroup を指定して TransitionManager.beginDelayedTransition() を呼ぶと、ViewGroup の子 View が変わったときに自動でいい感じにアニメーションしてくれます。 この方法だと Scene を作る必要はありません。

# やってみたけど、アニメーションしてくれない。。。 final ViewGroup view = (ViewGroup) findViewById(android.R.id.content); TransitionManager.beginDelayedTransition(view); findViewById(R.id.button1).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { View inflate = LayoutInflater.from(MainActivity.this).inflate(R.layout.scene2, view, false); view.addView(inflate); } }); 特定のアニメーションを指定するには、Transition を継承したクラスのインスタンスを指定します。 指定されていないときは AutoTransition が利用されます。 Fade や ChangeBounds、Visibility などいくつかのクラスがあらかじめ用意されています。 Scene scene = Scene.getSceneForLayout(view, R.layout.scene2, MainActivity.this); TransitionManager.go(scene, new ChangeBounds()); res/transition/ に定義した XML ファイルに対して TransitionInflater.inflateTransition() を使うことでも Transition のインスタンスを作成することができます。 XML については TransitionManager のドキュメントの説明を読むのがいいです。



# 今のところ、これだ!という使い道がわかってません。。。w



明日は @checkela さんです!

2013年11月11日月曜日

ViewPager で Volley の NetworkImageView を使うときの注意点

ViewPager の子要素は、ページが変わったときにも onLayout() が呼ばれます。

整理すると、ページが切り替わると
・新しく生成されたページ(PagerAdapter の instantiateItem() が呼ばれるところ)では onLayout(true, ...) が呼ばれる
・現在の子要素全てで onLayout(false, ...) が呼ばれる


Volley の NetworkImageView (2013年11月11日)では、 @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); loadImageIfNecessary(true); } のようになっています。

これだとページを切り替えるたびに画像の読み込みが実行されてしまいます。

changed が true のときだけにすれば、新しく生成されたときだけ実行されるようになります。 @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); if (changed) { loadImageIfNecessary(true); } }


2013年10月24日木曜日

2.x と 3.x+ の Recent Apps からの起動は Intent に付く Flag が異なる

Suica Reader で試した結果なので、launchMode の設定によって変わってくるかもしれません。
Suica Reader では launchMode に singleTask を設定しています。

■ 2.3.6 (Nexus S)
NfcAdapter.ACTION_TECH_DISCOVERED による起動
flag = 268435456 = 0x10000000
FLAG_ACTIVITY_NEW_TASK

上記のあと、Recent Apps から起動
flag = 269484032 = 0x10100000
FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY


■ 4.3 (初代 Nexus 7)
NfcAdapter.ACTION_TECH_DISCOVERED による起動
flag = 0

上記のあと、Recent Apps から起動
flag = 269500416 = 0x10104000
FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY | FLAG_ACTIVITY_TASK_ON_HOME


2.3.6 との違いは FLAG_ACTIVITY_TASK_ON_HOME です。この Flag は API Level 11 からです。

2.3.6 と 4.3 では NfcAdapter.ACTION_TECH_DISCOVERED による起動時の Flag も違っていました。

Recent から起動したときの Intent は前回起動したときの Intent になります。
つまり、カードをかざして起動したあと、Recent から起動すると、カードをかざしていないのに Intent の Action は NfcAdapter.ACTION_NDEF_DISCOVEREDNfcAdapter.ACTION_TECH_DISCOVERED などになります。
NDEF データは Intent に保持されるので、それを利用する場合は問題ないのですが、カード検出後にカードと通信して直接データを読み取る場合は困ります。
カードがかざされたことによる起動なのか、Recent からの起動なのか調べるために Flag が 0 より大きいかどうかでチェックしていたのですが、2.3.6 では NfcAdapter.ACTION_TECH_DISCOVERED による起動時の Flag が 0 ではないことがわかってしまい、ちゃんと FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY をチェックしないとダメでしたね。。。



2013年10月23日水曜日

AppCompat は Dialog をサポートしていない

AppCompat には Dialog 系のテーマがありません。

AppCompat で用意されているテーマ
  • Theme.AppCompat
  • Theme.AppCompat.Light
  • Theme.AppCompat.Light.DarkActionBar


次のように自分でダイアログのテーマを作ることはできます。
android:windowBackground に指定する画像(下記だと @drawable/dialog_full_holo_light)を platform から取ってこないといけないですが。 <resources xmlns:android="http://schemas.android.com/apk/res/android"> <style name="AppBaseTheme" parent="Theme.AppCompat.Light"></style> <style name="AppTheme" parent="AppBaseTheme"> <item name="android:windowBackground">@drawable/bg</item> </style> <style name="AppTheme.Dialog"> <item name="android:windowFrame">@null</item> <item name="android:windowBackground">@drawable/dialog_full_holo_light</item> <item name="android:windowIsFloating">true</item> <item name="android:windowContentOverlay">@null</item> <item name="android:windowSoftInputMode">stateUnspecified|adjustPan</item> <item name="android:windowActionBar">false</item> <item name="android:windowNoTitle">true</item> <item name="android:windowActionModeOverlay">true</item> <item name="android:windowCloseOnTouchOutside">false</item> </style> </resources> ただし、 <item name="android:windowActionBar">true</item> <item name="android:windowNoTitle">false</item> とすると、IllegalStateException が発生します。 java.lang.IllegalStateException: ActionBarImpl can only be used with a compatible window decor layout つまり、Dialog で AppCompat の ActionBar は利用できないようになっている、ということです。


なので、View で実装した DialogFragment を用意することになるのかな。 public class SimpleDialogFragment extends DialogFragment { public static SimpleDialogFragment getInstance(String title, String message) { Bundle args = new Bundle(); args.putString("title", title); args.putString("message", message); SimpleDialogFragment f = new SimpleDialogFragment(); f.setArguments(args); return f; } @Override public Dialog onCreateDialog(Bundle savedInstanceState) { Bundle args = getArguments(); String title = args.getString("title"); String message = args.getString("message"); View view = LayoutInflater.from(getActivity()) .inflate(R.layout.fragment_dialog, null); TextView tv; // title tv = (TextView) view.findViewById(R.id.title); tv.setText(title); // message tv = (TextView) view.findViewById(R.id.message); tv.setText(message); Dialog dialog = new Dialog(getActivity(), R.style.AppTheme_Dialog); dialog.setContentView(view); return dialog; } } <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" > <TextView android:id="@+id/title" style="?attr/actionBarStyle" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:gravity="center_vertical" android:paddingLeft="16dp" android:paddingRight="16dp" android:textAppearance="?android:attr/textAppearanceMedium" /> <ScrollView android:layout_width="match_parent" android:layout_height="match_parent" > <TextView android:id="@+id/message" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="16dip" android:autoLink="web|email" android:linksClickable="true" /> </ScrollView> </LinearLayout>



# ちなみに ActionBar Sherlock は Dialog サポートしてます