2019年8月6日火曜日

AlertDialog の Negative ボタンの文字色を変える

AndroidX の AlertDialog で Negative ボタンと Positive ボタンを出すと、両方文字が colorAccent の色になります。 import androidx.appcompat.app.AlertDialog class SampleActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_sample) button.setOnClickListener { AlertDialog.Builder(this) .setMessage("メッセージ") .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok, null) .show() } } }

Negative ボタンの文字色を変えるには Widget.AppCompat.Button.ButtonBar.AlertDialog を継承した style を用意し、android:textColor を指定します。 <style name="Widget.Sample.Button.ButtonBar.AlertDialog.Negative" parent="Widget.AppCompat.Button.ButtonBar.AlertDialog"> <item name="android:textColor">?android:attr/textColorSecondary</item> </style> ThemeOverlay.AppCompat.Dialog.Alert を継承したテーマを用意し、その中で buttonBarNegativeButtonStyle に上で定義した style を指定します。 <style name="ThemeOverlay.Sample.Dialog.Alert" parent="ThemeOverlay.AppCompat.Dialog.Alert"> <item name="buttonBarNegativeButtonStyle">@style/Widget.Sample.Button.ButtonBar.AlertDialog.Negative</item> </style> 用意したテーマを AlertDialog.Builder のコンストラクタで指定するか AlertDialog.Builder(this, R.style.ThemeOverlay_Sample_Dialog_Alert) .setMessage("メッセージ") .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok, null) .show() alertDialogTheme に指定します。 <style name="Theme.Sample.Light" parent="Theme.AppCompat.Light"> <item name="alertDialogTheme">@style/ThemeOverlay.Sample.Dialog.Alert</item> </style>


AppCompat の設定と解説

alertDialogStyle に指定されている AlertDialog.AppCompat をたどっていくと、 <style name="Base.V7.Theme.AppCompat" parent="Platform.AppCompat"> … <item name="alertDialogStyle">@style/AlertDialog.AppCompat</item> </style> <style name="AlertDialog.AppCompat" parent="Base.AlertDialog.AppCompat"/> <style name="Base.AlertDialog.AppCompat" parent="android:Widget"> <item name="android:layout">@layout/abc_alert_dialog_material</item> <item name="listLayout">@layout/abc_select_dialog_material</item> <item name="listItemLayout">@layout/select_dialog_item_material</item> <item name="multiChoiceItemLayout">@layout/select_dialog_multichoice_material</item> <item name="singleChoiceItemLayout">@layout/select_dialog_singlechoice_material</item> <item name="buttonIconDimen">@dimen/abc_alert_dialog_button_dimen</item> </style> layout に @layout/abc_alert_dialog_material が指定されています。この中で button bar 部分は @layout/abc_alert_dialog_button_bar_material に分割されています。

これを見ると Negative Button には style="?attr/buttonBarNegativeButtonStyle" が指定されていることがわかります。

@layout/abc_alert_dialog_button_bar_material <ScrollView …> <android.support.v7.widget.ButtonBarLayout …"> … <Button android:id="@android:id/button2" style="?attr/buttonBarNegativeButtonStyle" android:layout_width="wrap_content" android:layout_height="wrap_content"/> <Button android:id="@android:id/button1" style="?attr/buttonBarPositiveButtonStyle" android:layout_width="wrap_content" android:layout_height="wrap_content"/> </android.support.v7.widget.ButtonBarLayout> </ScrollView> buttonBarNegativeButtonStyle には buttonBarButtonStyle が指定されており、buttonBarButtonStyle には Widget.AppCompat.Button.ButtonBar.AlertDialog が指定されています。 <style name="Base.V7.Theme.AppCompat" parent="Platform.AppCompat"> … <item name="buttonBarButtonStyle">@style/Widget.AppCompat.Button.ButtonBar.AlertDialog</item> <item name="buttonBarPositiveButtonStyle">?attr/buttonBarButtonStyle</item> <item name="buttonBarNegativeButtonStyle">?attr/buttonBarButtonStyle</item> <item name="buttonBarNeutralButtonStyle">?attr/buttonBarButtonStyle</item> </style> Widget.AppCompat.Button.ButtonBar.AlertDialog は最終的に Widget.AppCompat.Button.Borderless.Colored を継承しており、これによりボタンの文字が colorAccent 色になっています。 <style name="Widget.AppCompat.Button.ButtonBar.AlertDialog" parent="Base.Widget.AppCompat.Button.ButtonBar.AlertDialog"/> <style name="Base.Widget.AppCompat.Button.ButtonBar.AlertDialog" parent="Widget.AppCompat.Button.Borderless.Colored"> <item name="android:minWidth">64dp</item> <item name="android:minHeight">@dimen/abc_alert_dialog_button_bar_height</item> </style> Widget.AppCompat.Button.ButtonBar.AlertDialog を継承した style を用意し、文字色を上書きして buttonBarButtonStyle にセットすれば Negative Button の文字色を変えることができます。



2019年8月5日月曜日

SavedStateHandle を Dagger で生成させてる ViewModel で使う方法(AssistedInject を使わない方法)

(AssistedInject を使った方法はぐぐれば出てくるのでぐぐってください)

この内容は技術書典7で頒布予定の Master of Dagger(仮)の正式版にも掲載予定です。
ここには詳しい解説は書かないので、ぜひ Master of Dagger(仮)の正式版を買ってください。



0.

このような ViewModel があります。(Dagger で生成させてる MainViewModel のコンストラクタに SavedStateHandle を追加した) class MainViewModel @Inject constructor( private val api: MyApi, private val handle: SavedStateHandle ) : ViewModel() { ... } MyApi は Dagger で管理しています。 @Module class AppModule { @Singleton @Provides fun provideApi(): MyApi { return ... } } MainViewModel は ViewModel の Map Multibindings の1要素になっています。 @Module interface BindModule { @Binds @IntoMap @ViewModelKey(MainViewModel::class) fun bindMainViewModel(viewModel: MainViewModel): ViewModel }

1.

SavedStateHandle をオブジェクトグラフに含む Subcomponent を用意します。 @Module(subcomponents = [SavedStateViewModelComponent::class]) class SavedStateViewModelComponentModule @Subcomponent interface SavedStateViewModelComponent { @Subcomponent.Factory interface Factory { fun create( @BindsInstance handle: SavedStateHandle ): SavedStateViewModelComponent } fun providers(): Map<Class<out ViewModel>, Provider<ViewModel>> }

2.

次に AbstractSavedStateViewModelFactory を継承した Factory を用意します。

ポイントはコンストラクタで先ほど作った SavedStateViewModelComponent.Factory を受け取るようにし、create() でそれを利用して ViewModel の Map Multibindings を受け取るようにすることです。 class SavedStateViewModelFactory @Inject constructor( private val factory: SavedStateViewModelComponent.Factory, owner: SavedStateRegistryOwner, defaultArgs: Bundle? = null ) : AbstractSavedStateViewModelFactory(owner, defaultArgs) { override fun <T : ViewModel?> create( key: String, modelClass: Class<T>, handle: SavedStateHandle ): T { val providers = factory.create(handle).providers() val found = providers.entries.find { modelClass.isAssignableFrom(it.key) } val provider = found?.value ?: throw IllegalArgumentException("unknown model class $modelClass") try { @Suppress("UNCHECKED_CAST") return provider.get() as T } catch (e: Exception) { throw RuntimeException(e) } } }

3.

SavedStateRegistryOwner と Bundle? をオブジェクトグラフに含む Subcomponent を用意します。
SavedStateComponent の modules に SavedStateViewModelComponentModule を指定します。 @Module(subcomponents = [SavedStateComponent::class]) class SavedStateComponentModule @Subcomponent(modules = [SavedStateViewModelComponentModule::class]) interface SavedStateComponent { @Subcomponent.Factory interface Factory { fun create( @BindsInstance owner: SavedStateRegistryOwner, @BindsInstance defaultArgs: Bundle? ): SavedStateComponent } fun viewModelFactory(): SavedStateViewModelFactory }

4.

AppComponent の modules に SavedStateComponentModule を指定します。 @Singleton @Component( modules = [ AppModule::class, BindModule::class, SavedStateComponentModule::class ] ) interface AppComponent { fun savedStateComponentFactory(): SavedStateComponent.Factory }

5.

SavedStateComponentから取得したFactoryを使います。 class MainActivity : AppCompatActivity() { private val viewModel: MainViewModel by viewModels { (application as MyApplication).appComponent .savedStateComponentFactory() .create(this, null) .viewModelFactory() } ... }

2019年7月3日水曜日

BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT 編 : ViewPager + FragmentPagerAdapter での onResume() の挙動

ViewPager + FragmentPagerAdapter での setVisibleUserHint の挙動」の BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT 編です。


FragmentPagerAdapter の getItem() で返す Fragment に次のようにログを入れました。 class SimpleFragment : Fragment() { override fun onStart() { super.onStart() println("$position : onStart") } override fun onResume() { super.onResume() println("$position : onResume") } override fun onPause() { super.onPause() println("$position : onPause") } override fun onStop() { super.onStop() println("$position : onStop") } }

1. レイアウトより前のタイミング(例えば onCreate())で adapter をセットしている場合 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) viewPager.adapter = MyPager(supportFragmentManager, FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) } } この場合、ログは次のようになります。 System.out: 0 : onStart System.out: 1 : onStart System.out: 0 : onResume つまり
  • 1. position 0 の Fragment の onStart()
  • 2. position 1 の Fragment の onStart()
  • 3. position 0 の Fragment の onResume()
という流れです。



2. レイアウトより前のタイミング(例えば onCreate())で adapter をセットし、currentItem を変更している場合

onCreate() で ViewPager に adapter をセットした後 currentItem を 1 にしてみます。 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) viewPager.adapter = MyPager(supportFragmentManager, FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) viewPager.currentItem = 1 } } この場合のログはこうなります。 System.out: 1 : onStart System.out: 0 : onStart System.out: 2 : onStart System.out: 1 : onResume つまり
  • 1. position 1 の Fragment の onStart()
  • 2. position 0 の Fragment の onStart()
  • 3. position 2 の Fragment の onStart()
  • 4. position 1 の Fragment の onResume()
という流れです。



3. レイアウトより後のタイミングで adapter をセットしている場合 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) Handler().postDelayed( { viewPager.adapter = MyPager(supportFragmentManager, FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) }, 1000 ) } } onCreate() から1秒後に adapter をセットしてみます。この場合ログは次のようになります。 System.out: 0 : onStart System.out: 1 : onStart System.out: 0 : onResume 「1. レイアウトより前のタイミング(例えば onCreate())で adapter をセットしている場合」と同じですね。
  • 1. position 0 の Fragment の onStart()
  • 2. position 1 の Fragment の onStart()
  • 3. position 0 の Fragment の onResume()
という流れです。



4. レイアウトより後のタイミングで adapter をセットし、currentItem を変更している場合

onCreate() から1秒後に adapter をセットし、currentItem を 1 にしてみます。 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) Handler().postDelayed( { viewPager.adapter = MyPager(supportFragmentManager, FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) viewPager.currentItem = 1 }, 1000 ) } } この場合ログは次のようになります。 System.out: 0 : onStart System.out: 1 : onStart System.out: 0 : onResume System.out: 2 : onStart System.out: 0 : onPause System.out: 1 : onResume 「2. レイアウトより前のタイミング(例えば onCreate())で adapter をセットし、currentItem を変更している場合」と同じになりません!

まず「3. レイアウトより後のタイミングで adapter をセットしている場合」と同じことが起こります。

つまり
  • 1. position 0 の Fragment の onStart()
  • 2. position 1 の Fragment の onStart()
  • 3. position 0 の Fragment の onResume()
という流れが最初にあります。

その後
  • 4. position 2 の Fragment の onStart()
  • 5. position 0 の Fragment の onPause()
  • 6. position 1 の Fragment の onResume()
という流れになります。

adapter をセットしたタイミングで 1 〜 3 までの流れが起こり、currentItem を 1 にしたタイミングで 4 〜 6 の流れが起こります。

このように、レイアウトよりも後のタイミングで adapter をセットすると、その時点で position 0 の Fragment を PrimaryItem として onResume() まで呼ばれてしまいます。

そのため、レイアウトよりも後のタイミングで adapter をセットして currentItem を 0 以外に変更する場合、「Fragment がユーザーに表示されたタイミングで xx したい」処理のトリガーとして onResume() を使うには注意が必要です。

adapter がセットされている間の onResume() に反応してしまうと、その後 currentItem が変更されて実際にはユーザーにはほぼ見えない Fragment でも処理が走ってしまうため、adapter がセットされている間だけフラグを立てておいて onResume() で反応しないような工夫が必要になります。