20210915のAndroidに関する記事は6件です。

Jetpack Compose でログインフォームを作る

はじめに Jetpack Compose で入力フォームを作成する方法を記載します。画面に配置する要素は3つで、メールアドレス入力欄、パスワード入力欄、ボタンです。 Empty Compose Activity から作り始めた場合、おそらく追加のパッケージは必要ありません。 実装 まずはメールアドレス入力欄の実装です。 @Composable fun EmailTextField( value: String, modifier: Modifier = Modifier, onValueChange: (String) -> Unit, ) { val focusManager = LocalFocusManager.current OutlinedTextField( value = value, onValueChange = onValueChange, modifier = modifier, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Email, imeAction = ImeAction.Next, ), keyboardActions = KeyboardActions( onNext = { focusManager.moveFocus(FocusDirection.Down) } ), label = { Text("メールアドレス") }, ) } keyboardType = KeyboardType.Email とすることで、キーボードがメールアドレスの入力用になります。 ImeAction.Next はキーボードの右下にあるボタンの形式を表します。keyboardOptions の imeAction = ImeAction.XXX と keyboardActions の onXXX は密接に関係しており、たとえば imeAction = ImeAction.Next と設定すると onNext で渡した関数が呼ばれます。 focusManager.moveFocus(FocusDirection.Down) により次のフォーカス先にフォーカスを移すことができます。 次はパスワード入力欄の実装です。 @Composable fun PasswordTextField( value: String, modifier: Modifier = Modifier, onValueChange: (String) -> Unit, submit: () -> Unit, ) { val focusManager = LocalFocusManager.current OutlinedTextField( value = value, onValueChange = onValueChange, modifier = modifier, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done, ), keyboardActions = KeyboardActions( onDone = { focusManager.clearFocus() submit() } ), visualTransformation = PasswordVisualTransformation(), label = { Text("パスワード") }, ) } 今回はキーボードで決定を押すと submit 処理が走ってほしいため、submit 関数を受け取るようにしています。 メールアドレス入力欄とほぼ同じですが、visualTransformation = PasswordVisualTransformation() という見慣れない記述があります。これは、パスワードを非表示にする変換処理を登録しています。 最後にボタンの実装です。 @Composable fun LoginButton( ajax: Boolean, modifier: Modifier = Modifier, submit: () -> Unit, ) { Button(onClick = submit, enabled = !ajax, modifier = modifier) { if (ajax) { CircularProgressIndicator( strokeWidth = 2.dp, modifier = Modifier.size(24.dp) ) } else { Text("ログイン") } } } 処理中はボタンイベントを無効にしたいため enabled = !ajax としています。 これらの要素を取り入れたフォームの実装は次のようになります。 @Composable fun LoginForm() { var ajax by remember { mutableStateOf(false) } var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } val scope = rememberCoroutineScope() val submit = { scope.launch(Dispatchers.IO) { ajax = true // ここにログイン処理を記述する ajax = false } Unit } Column( modifier = Modifier .fillMaxSize() .padding(16.dp) ) { EmailTextField(email, Modifier.fillMaxWidth()) { email = it } Spacer(Modifier.height(4.dp)) PasswordTextField(password, Modifier.fillMaxWidth(), { password = it }, submit) Spacer(Modifier.height(12.dp)) LoginButton(ajax, Modifier.fillMaxWidth().height(56.dp), submit) } } 入力文字列などの情報はフォームに持たせています。こうすることで、ログイン処理を行うときに入力文字列を利用することができます。 3つの各要素では modifier を引数として受け取っていました。そのように実装することで、呼び出し側で細かなスタイルの調節ができるようになります。 終わりに メールアドレス入力欄からパスワード入力欄にフォーカスを移動させるとき、はじめは方法が思い浮かばずに悩んでいましたが、LocalFocusManager.current を使うことでフォーカスを次に移す処理が簡単に実装できます。参考にしていただけると嬉しいです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Kotlin】冗長的なレイアウトファイルからの脱却

アンケート画面などで複数のTextViewやCheckBoxが繰り返し表示される画面を作成するときレイアウトファイル(.xml)が冗長的になってしまうことがあるかと思います。 以下の画像のようなチェックボックスが複数個並んだViewを作成するとします。 この時のレイアウトファイルとして真っ先に考えられるのはこんな感じかなっと思います。 <?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> <data> </data> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".view.MainActivity"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <CheckBox android:id="@+id/checkBox1" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="チェック項目1" /> <CheckBox android:id="@+id/checkBox2" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="チェック項目2" /> <CheckBox android:id="@+id/checkBox3" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="チェック項目3" /> <CheckBox android:id="@+id/checkBox4" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="チェック項目4" /> <CheckBox android:id="@+id/checkBox5" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="チェック項目5" /> <CheckBox android:id="@+id/checkBox6" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="チェック項目6" /> </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout> </layout> この記事ではこのような冗長的なレイアウトファイル(CheckBoxが描画したい数だけレイアウトファイルに繰り返されている)から脱却する実装方法を紹介します。 例でチェックボックスを扱っていますがTextViewやImageViewでも同様に実装できます。 描画するCheckboxのenum classを作成 最初に描画するチェックボックスをそれぞれenumで定義します。 CheckboxList.kt // isCheckはデフォルトでチェックをつけるかのフラグ enum class CheckboxList(@StringRes val title: Int, val isCheck: Boolean) { FIRST(R.string.checkbox1, true), SECOND(R.string.checkbox2, false), THIRD(R.string.checkbox3, false), FORTH(R.string.checkbox4, false), FIFTH(R.string.checkbox5, true); } .ktファイルで実装する CheckBoxやTextViewなどはActivityやFragmentの中でも生成することができるので、そちらを利用します。 ※DataBindingを使用します。 MainActivity.kt class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) createCheckBox() } private fun createCheckBox() { /** * CheckboxListに定義されている要素の数だけ繰り返す。 */ CheckboxList.values().forEachIndexed { index, checkboxList -> val checkbox = Checkbox(requireContext()) // Checkboxインスタンスの生成 checkbox.apply { id = index // チェックボックスのリソースIDを設定(今回は0~4) text = requireContext().resources.getText(checkboxList.title) // enumに定義されたタイトルをセット isCheck = checkboxList.isCheck } binding.linearView.addView(checkbox) // linearView(LinearLayout)にcheckboxを追加 } } Activity_main.xml <?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> <data> </data> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".view.MainActivity"> <LinearLayout android:id="@+id/linear_view" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout> </layout> このようにコードベースで実装することで冗長的なレイアウトファイルから脱却できます。 また、チェックボックスが追加になった場合でもenumに要素を追加すればいいため修正コストも下がると思います。 今回はチェックボックスで実装しましたが、TextViewやButton、ImageViewでもできると思うので冗長的なレイアウトになりそうな時はこちらの手法も考えて見てはいかがでしょうか?
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

プログラムでOSはHarmony(HarmonyOS)かAndroidか判断する方法

ファーウェイは2021年7月23日にHUAWEI MatePad 11をリリースしました。このタブレットはHarmonyOSを搭載しています。AndroidのアプリもHarmonyOSのアプリも正常動作します。 アプリの中でOSがHarmonyOSかAndroidか判断する場面があるかもしれないので、その方法を公開します。 public boolean isHarmonyOS() { try { Class classType = Class.forName("com.huawei.system.BuildEx"); Method method = classType.getMethod("getOsBrand"); ClassLoader classLoader = classType.getClassLoader(); if (classLoader != null && classLoader.getParent() == null) { return "harmony".equals(method.invoke(classType)); } } catch (ClassNotFoundException e) { } catch (NoSuchMethodException e) { } catch (Exception e) { } return false; }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Universal Android Music Player(UAMP)のカスタマイズ (メニューの変更 2)

はじめに Android用アプリのプログラミングを学ぶため、Googleが公開しているサンプルコード Universal Android Music Player(UAMP)をカスタマイズしてみます。 メニューをカスタマイズするために、オリジナルのコードの動作を確認します。 今回は、メニュー画面で項目がタップされてから下位のメニューを表示するまでの動作を確認します。 環境 PC MacBook Pro 16 2.3 GHz 8コアIntel Core i9 16 GB 2667 MHz DDR4 macOS Big Sur ver.11.5.2 開発用SW Android Studio 4.2.1 Target Device (Virtual Device) Category: Phone Name: Pixcel 2 Resolution: 1080x1920 420bpi API Level: 28 Android: 9.0 CPU: x86 コードの確認 メニュー項目の選択(下位のメニューが存在する場合) メニュー画面で項目がタップされてから下位のメニューを表示するまでの流れは下図のとおり。 (図が細かくなってしまいました。拡大してご確認ください。) メニュー選択フロー.png コードの関連する部分は下記。 なお、下記のコードは、音源を端末内のローカルファイルに変更済みのもの。 MainActivity.kt class MainActivity : AppCompatActivity() { //途中省略 //mediaIdで指定されたTagのFragmentの存在を確認し、なければ生成して表示する関数。 private fun navigateToMediaItem(mediaId: String) { //mediaIdで指定されたTagのFragmentを探す。 var fragment: MediaItemFragment? = getBrowseFragment(mediaId) //mediaIdで指定されたTagのFragmentが存在しなければ生成する。 if (fragment == null) { //次の画面のFragmentインスタンスを生成する。 fragment = MediaItemFragment.newInstance(mediaId) //Fragmentの変更を要求する。 viewModel.showFragment(fragment, !isRootId(mediaId), mediaId) } } //途中省略 //mediaIdで指定されたTagのFragmentを探し、結果をMediaItemFragmentにcastして返す関数。 private fun getBrowseFragment(mediaId: String): MediaItemFragment? { return supportFragmentManager.findFragmentByTag(mediaId) as MediaItemFragment? } //途中省略 //オリジナルコードのonCreate()で実施していた処理を行うために作成した関数。 //アクティビティーを生成する。 private fun activityCreationProcess():Unit{ //途中省略 //メニュー変更イベント(MainActivityViewModelのnavigateToMediaItem)を監視。 //変化があればFragmentを更新する。 //navigationToMediaItem()の処理で、Fragment変更指示(navigateToFragment)が変更され、 //上で設定したobserverがこれを検知してFragmentの更新処理を行う。 viewModel.navigateToMediaItem.observe(this, Observer { it?.getContentIfNotHandled()?.let { mediaId -> navigateToMediaItem(mediaId) } }) } //以下省略 MainActivityViewModel.kt class MainActivityViewModel( private val musicServiceConnection: MusicServiceConnection ) : ViewModel() { //途中省略 //メニュー更新指示イベント。 val navigateToMediaItem: LiveData<Event<String>> get() = _navigateToMediaItem private val _navigateToMediaItem = MutableLiveData<Event<String>>() //途中省略 //Fragment更新指示イベント。 val navigateToFragment: LiveData<Event<FragmentNavigationRequest>> get() = _navigateToFragment private val _navigateToFragment = MutableLiveData<Event<FragmentNavigationRequest>>() //途中省略 //Fragmentの更新を要求する関数。この関数が呼び出されると、Fragment更新指示イベントを発生する。 //イベントは、getContentIfNotHandled()で一度だけ読み出すことが可能。 fun showFragment(fragment: Fragment, backStack: Boolean = true, tag: String? = null) { _navigateToFragment.value = Event(FragmentNavigationRequest(fragment, backStack, tag)) } //途中省略 //タップされたメニュー項目に応じた処理を行う関数 fun mediaItemClicked(clickedItem: MediaItemData) { if (clickedItem.browsable) { //タップされた項目がbrowsable(階層化されたコンテンツリストで下位のコンテンツリストが存在する)であればメニュー変更イベント(navigateToMediaItem)を発生。 //メニュー変更イベントは、メニュー表示更新のトリガーとなる。 browseToItem(clickedItem) } else { //タップされた項目がbrowsableでなければ、選択されたコンテンツ(楽曲)を再生し、画面を再生画面に切り替える。 playMedia(clickedItem, pauseAllowed = false) showFragment(NowPlayingFragment.newInstance()) } } //途中省略 //メニュー変更イベントを発生する関数。 private fun browseToItem(mediaItem: MediaItemData) { _navigateToMediaItem.value = Event(mediaItem.mediaId) } //以下省略 MediaItemFragment.kt class MediaItemFragment : Fragment() { //途中省略 private lateinit var mediaId: String private lateinit var binding: FragmentMediaitemListBinding //リストアダプターを設定 //MediaItemAdapterの引数 "itemClickedListener"をラムダ式で設定。 //タップされたメニュー項目のMediaItemDataをmainActivityVMの関数mediaItemClicked()に引数として渡して実行する。 private val listAdapter = MediaItemAdapter { clickedItem -> mainActivityViewModel.mediaItemClicked(clickedItem) } //途中省略 //ビューの生成 override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { binding = FragmentMediaitemListBinding.inflate(inflater, container, false) return binding.root } //新たなアクティビティが生成された際の処理 override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) mediaId = arguments?.getString(MEDIA_ID_ARG) ?: return //メニュー項目リスト(mediaItemFragmentVMのmediaItems)の監視を設定。 mediaItemFragmentViewModel.mediaItems.observe(viewLifecycleOwner, Observer { list -> //loadSpinner(処理中を示すぐるぐる表示)の表示制御 binding.loadingSpinner.visibility = //メニュー項目リストが空のときは、loadSpinnerを表示する。 if (list?.isNotEmpty() == true) View.GONE else View.VISIBLE //メニュー項目を更新する listAdapter.submitList(list) }) //途中省略 //リサイクルビューにアダプターを設定する。 binding.list.adapter = listAdapter } } MediaItemAdapter.kt class MediaItemAdapter( //引数は関数型。MediaItemDataが引数として渡され、結果を返さない関数を引数とする。 private val itemClickedListener: (MediaItemData) -> Unit ) : ListAdapter<MediaItemData, MediaViewHolder>(MediaItemData.diffCallback) { //表示するメニュー項目ごとにビューホルダーを生成する override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { val inflater = LayoutInflater.from(parent.context) val binding = FragmentMediaitemBinding.inflate(inflater, parent, false) //メニュー項目がタップされたときに、関数型の引数itemClickedListenerが実行されるよう、ビューホルダーに設定。 return MediaViewHolder(binding, itemClickedListener) } //メニュー項目として表示データを設定する override fun onBindViewHolder( holder: MediaViewHolder, position: Int, payloads: MutableList<Any> ) { //表示対象のメニュー項目の情報を取得する。 val mediaItem = getItem(position) //アプリ起動、子メニュー選択などでメニュー画面を表示する際は、表示する全メニュー項目でpayloadはsize=0で、fullRefresh=Trueとなる。 //画面をスクロールして新たなメニュー項目を表示するときは、新たな項目をpayloadはsize=0、fullRefresh=Trueで表示する(既に表示されていたメニュー項目については、onBindingViewHolder()はcallされない)。 //画面をスクロールして、以前表示していた項目を再表示するときは、onBindingViewHolder()はcallされない。 //楽曲の再生が終了し、次の楽曲の再生を開始するときの再生状態(playbackState)の変化の際は、終了した楽曲と再生を開始する楽曲のメニュー項目の表示が、payloadのsize=1(isNotEmpty()=True)、payload=Trueで再生状態を示すアイコンのみ更新する。 var fullRefresh = payloads.isEmpty() if (payloads.isNotEmpty()) { payloads.forEach { payload -> when (payload) { PLAYBACK_RES_CHANGED -> { holder.playbackState.setImageResource(mediaItem.playbackRes) } //payloadがsize != 0(isNotEmpty()=True)であっても、解読不能であればfullRefreshする。 else -> fullRefresh = true } } } //onBindingViewHolder()がcallされるときは、たいていfullRefresh。fullRefreshではないのは、再生状態を示すアイコンの変更時など。 if (fullRefresh) { holder.item = mediaItem holder.titleView.text = mediaItem.title holder.subtitleView.text = mediaItem.subtitle holder.playbackState.setImageResource(mediaItem.playbackRes) Glide.with(holder.albumArt) .load(mediaItem.albumArtUri) .placeholder(R.drawable.default_art) .into(holder.albumArt) } } override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { onBindViewHolder(holder, position, mutableListOf()) } } class MediaViewHolder( binding: FragmentMediaitemBinding, itemClickedListener: (MediaItemData) -> Unit ) : RecyclerView.ViewHolder(binding.root) { //メニュー画面タイトル行の設定 val titleView: TextView = binding.title //メニュー画面サブタイトル行の設定 val subtitleView: TextView = binding.subtitle //メニュー画面アルバム画像の設定 val albumArt: ImageView = binding.albumArt //メニュー画面再生状態表示の設定 //楽曲メニュー画面でアルバム画像上に再生状態を示すアイコンを表示する。 val playbackState: ImageView = binding.itemState var item: MediaItemData? = null init { //メニュー項目がタップされたときの動作を設定 binding.root.setOnClickListener { //引数として与えられられた関数itemClickedListener()を実行する。 item?.let { itemClickedListener(it) } } } } MusicServiceConnection.kt class MusicServiceConnection(context: Context, serviceComponent: ComponentName) { //途中省略 //mediaBrowserServiceに対して、mediaIdで指定した階層のコンテンツリストを要求 fun subscribe(parentId: String, callback: MediaBrowserCompat.SubscriptionCallback) { mediaBrowser.subscribe(parentId, callback) } //途中省略 companion object { @Volatile private var instance: MusicServiceConnection? = null //インスタンスを通知する関数 fun getInstance(context: Context, serviceComponent: ComponentName) = instance ?: synchronized(this) { instance ?: MusicServiceConnection(context, serviceComponent) .also { instance = it } } } } //以下省略 MusicService.kt open class MusicService : MediaBrowserServiceCompat() { //途中省略 //階層化されたコンテンツリストの生成 private val browseTree: BrowseTree by lazy { //変数が初めて使用される際に初期化される BrowseTree(applicationContext, mediaSource) //途中省略 //subscribe()への応答として、要求された階層のコンテンツのリストを返す override fun onLoadChildren( parentMediaId: String, result: Result<List<MediaItem>> ) { if (parentMediaId == UAMP_RECENT_ROOT) { result.sendResult(storage.loadRecentSong()?.let { song -> listOf(song) }) } else { val resultsSent = mediaSource.whenReady { successfullyInitialized -> if (successfullyInitialized) { //楽曲情報リストの取得が完了したら、submit()の応答として、要求された階層のコンテンツのリストを返す val children = browseTree[parentMediaId]?.map { item -> MediaItem(item.description, item.flag) } result.sendResult(children) } else { //楽曲情報リストの取得に失敗したら、nullを返す。 mediaSession.sendSessionEvent(NETWORK_FAILURE, null) result.sendResult(null) } } // 楽曲情報リストの取得に失敗したら、メッセージを現在のスレッドから切り離し、後でsendResult()を実行できるようにする。 if (!resultsSent) { result.detach() } } } //以下省略       長くなってしまったので、ここで一旦切ります m(_ _)m 階層化されたコンテンツリストの生成、メニューのカスタマイズの実装などについては改めて投稿いたします。 参考 Androidデベロッパー RecyclerView で動的リストを作成する  Androidデベロッパー RecyclerView の高度なカスタマイズ 2020年版RecyclerViewの使い方 〜 RecyclerView + ListAdapter + DataBinding + LiveData + ViewModel 〜 [Android]RecyclerView の ListAdapter を viewBinding と組み合わせて使う方法 RecyclerViewでListAdapterを使う 関連する投稿 Universal Android Music Player(UAMP)のカスタマイズ (音源の変更 1/2) Universal Android Music Player(UAMP)のカスタマイズ (音源の変更 2/2) Universal Android Music Player(UAMP)のカスタマイズ (メニューの変更 1) おわりに プログラミング初心者、AndroidアプリのコードをいじるのもKotlinを使うのも初めてなので、誤りが多々あると思います。アドバイス、励ましのコメントなどいただけると嬉しいです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Flutterで手書きを実装する

実装する機能 今回実装する機能は、 自由に画面をなぞると特定の範囲内で手書きで絵が描けるようにする ひとつ戻る(undo)ひとつ進む(redo)ボタンの実装 全部消すボタンの実装 になります。類似した機能が入ったアプリだと消しゴムが入っていますが、今回のアプリではそこまで必要性を感じなかったため実装しません。 パッケージ・バージョン Flutterのバージョンは2.2.0です。 使用するパッケージは状態管理のためにhooks_riverpodとstatenotifier、freezedを使います。 特にそれ以外のパッケージは使用しません。 バージョンは以下の通りです。 pubspec.yaml dependencies: flutter_hooks: ^0.17.0 freezed_annotation: hooks_riverpod: ^0.14.0+4 dev_dependencies: build_runner: freezed: 手書きの実装 土台の作成 本題の実装になります。 今回の手書きの範囲は正方形にしたいので、画面中央に配置します。 draw_screen.dart class DrawScreen extends StatelessWidget { const DrawScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Appbar'), ), body: Center( child: Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.width, color: Colors.white, ), ), ); } } 画面をなぞった時に感知させたいため、GestureDetectorを使用します。 draw_screen.dart ... child: Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.width, color: Colors.white, child: GestureDetector( onPanStart: (details) {}, onPanUpdate: (details) {}, onPanEnd: (details) {}, ), ), ... ペイントの状態を管理 次に、GestureDetectorのそれぞれの関数の引数から、なぞっているポジションが取得できるので、それを格納するためのStateNotifierProviderを追加します。 draw_controller.dart // ステート @freezed abstract class DrawState with _$DrawState { const factory DrawState({ @Default(<List<Offset>>[]) List<List<Offset>> paintList, @Default(<List<Offset>>[]) List<List<Offset>> undoList, @Default(false) bool isDrag, }) = _DrawState; } // コントローラー final drawController = StateNotifierProvider.autoDispose<DrawController, DrawState>( (ref) => DrawController()); class DrawController extends StateNotifier<DrawState> { DrawController() : super(const DrawState()); void undo() { // ひとつ戻る } void redo() { // ひとつ進む } void clear() { // 全消し } void addPaint() { // ペイント開始 } void updatePaint() { // ペイント中 } void endPaint() { // ペイント終了 } } paintListは 描画開始から描画終了までの線 = なぞったポジションの集まり = List<Offset> かつ、その線を何本もかけるので、その集まり = List<List<Offset>> となります。 undoListはundoをした際に、paintListの最後の要素を格納して、redoの際にpaintListに戻すためのものなので 、同様にList<List<Offset>>になります。 isDragは描画中かどうかの判別用ステータスで、描画中にundoやredoなどを使えないようにするためのものです。 ではそれぞれの関数を実装していきます。 draw_controller.dart void undo() { // 描画中か、undoできなかったら何もしない if (state.isDrag || !canUndo) { return; } // paintListの最後を取って、undoListに追加する final _last = state.paintList.last; state = state.copyWith( undoList: List.of(state.undoList)..add(_last), paintList: List.of(state.paintList)..removeLast(), ); } void redo() { // 描画中か、redoできなかったら何もしない if (state.isDrag || !canRedo) { return; } // undoListの最後を取って、paintListに追加する final _last = state.undoList.last; state = state.copyWith( undoList: List.of(state.undoList)..removeLast(), paintList: List.of(state.paintList)..add(_last), ); } void clear() { // 全ての要素を空にするだけ if (!state.isDrag) { state = state.copyWith(paintList: [], undoList: []); } } void addPaint(Offset startPoint) { if (!state.isDrag) { state = state.copyWith( isDrag: true, // 描画中に変更 paintList: List.of(state.paintList)..add([startPoint]), // 新たに開始地点を追加 undoList: const [], // 一つ進めるものがないはずなので空に(redoできないように) ); } } void updatePaint(Offset nextPoint) { // 最後の要素に進んだポジションを追加 if (state.isDrag) { final _paintList = List<List<Offset>>.of(state.paintList); final _offsetList = List<Offset>.of(state.paintList.last)..add(nextPoint); _paintList.last = _offsetList; state = state.copyWith(paintList: _paintList); } } // 描画終了 void endPaint() => state = state.copyWith(isDrag: false); GestureDetectorと上の関数を繋げます。 flutter_hooksを使用しているため、StatelessWidgetをHookWidgetに変更し、runApp()にProviderScope()を追加することを忘れないでください。 draw_screen.dart ... @override Widget build(BuildContext context) { final _state = useProvider(drawController); final _controller = useProvider(drawController.notifier); ... child: GestureDetector( onPanStart: (details) => _controller.addPaint(details.localPosition), onPanUpdate: (details) { _controller.updatePaint(details.localPosition); }, onPanEnd: (_) => _controller.endPaint(), child: CustomPaint(painter: Signature(_state, context)), ), ... 描画する機能の実装 なぞったポジションをもとに描画する必要があるため、カスタムペインターを使います。 signature.dart class Signature extends CustomPainter { Signature(this.state, this.context); final DrawState state; final BuildContext context; @override void paint(Canvas canvas, Size size) { const strokeWigth = 12.0; final paint = Paint() ..color = Colors.black ..strokeCap = StrokeCap.round ..strokeWidth = strokeWigth; for (final points in state.paintList) { // 一番最初にタップした地点に点を打つ // そうしないとタップして離しただけの時に描画されない canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromCenter( center: points[0], width: strokeWigth, height: strokeWigth), const Radius.circular(strokeWigth), ), paint, ); // ひとかたまりの線の描画 for (var i = 0; i < points.length - 1; i++) { canvas.drawLine(points[i], points[i + 1], paint); } } } // paintListが変更されている時のみリビルド @override bool shouldRepaint(Signature oldDelegate) => oldDelegate.state.paintList != state.paintList; } これで一旦は描画できるようになっていると思います。 枠からはみ出さないよう修正 しかし、このままでは、範囲外まで描画できるようになっています。 要件を満たすように修正をしていきます 修正内容としては、枠を超えたら枠の端でとどまるようにします。 draw_screen.dart ... // ポジションの取得 Offset _getPosition(double length, Offset localPosition) { double _dx; double _dy; if (localPosition.dx < 0) { _dx = 0; } else if (localPosition.dx > length) { _dx = length; } else { _dx = localPosition.dx; } if (localPosition.dy < 0) { _dy = 0; } else if (localPosition.dy > length) { _dy = length; } else { _dy = localPosition.dy; } return Offset(_dx, _dy); } ... それと、onPanUpdateを次のように変更します onPanUpdate: (details) { _controller.updatePaint(_getPosition( MediaQuery.of(context).size.width, details.localPosition)); }, こうすることで、超えることがなくなったと思います。 undo,redo,全消しボタンの実装 最後にボタンの配置をします。 ボタンは左からundo, redo, 全消で配置します また、undo, redoできる時のみアクティブカラーにします。 draw_screen.dart Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ ElevatedButton( onPressed: _controller.undo, style: ElevatedButton.styleFrom( shape: const CircleBorder(), primary: _controller.canUndo ? Theme.of(context).accentColor : Colors.grey[200], onPrimary: Colors.white, padding: const EdgeInsets.all(10), ), child: const Icon(Icons.undo, size: 40), ), ElevatedButton( onPressed: _controller.redo, style: ElevatedButton.styleFrom( shape: const CircleBorder(), primary: _controller.canRedo ? Theme.of(context).accentColor : Colors.grey[200], padding: const EdgeInsets.all(10), onPrimary: Colors.white, ), child: const Icon(Icons.redo, size: 40), ), ElevatedButton( onPressed: _controller.clear, style: ElevatedButton.styleFrom( shape: const CircleBorder(), primary: Colors.red, padding: const EdgeInsets.all(10), onPrimary: Colors.white, ), child: const Icon(Icons.delete, size: 40), ), ], ), 以上になります!!! 完成図 コード全体 dart_screen.dart import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:test/draw_controller.dart'; class DrawScreen extends HookWidget { const DrawScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final _state = useProvider(drawController); final _controller = useProvider(drawController.notifier); return Scaffold( appBar: AppBar( title: const Text('Appbar'), ), body: Column( children: [ const Spacer(), Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.width, color: Colors.white, child: GestureDetector( onPanStart: (details) => _controller.addPaint(details.localPosition), onPanUpdate: (details) { _controller.updatePaint(_getPosition( MediaQuery.of(context).size.width, details.localPosition)); }, onPanEnd: (_) => _controller.endPaint(), child: CustomPaint(painter: Signature(_state, context)), ), ), const SizedBox(height: 60), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ ElevatedButton( onPressed: _controller.undo, style: ElevatedButton.styleFrom( shape: const CircleBorder(), primary: _controller.canUndo ? Theme.of(context).accentColor : Colors.grey[200], onPrimary: Colors.white, padding: const EdgeInsets.all(10), ), child: const Icon(Icons.undo, size: 40), ), ElevatedButton( onPressed: _controller.redo, style: ElevatedButton.styleFrom( shape: const CircleBorder(), primary: _controller.canRedo ? Theme.of(context).accentColor : Colors.grey[200], padding: const EdgeInsets.all(10), onPrimary: Colors.white, ), child: const Icon(Icons.redo, size: 40), ), ElevatedButton( onPressed: _controller.clear, style: ElevatedButton.styleFrom( shape: const CircleBorder(), primary: Colors.red, padding: const EdgeInsets.all(10), onPrimary: Colors.white, ), child: const Icon(Icons.delete, size: 40), ), ], ), const Spacer(), ], ), ); } // ポジションの取得 Offset _getPosition(double length, Offset localPosition) { double _dx; double _dy; if (localPosition.dx < 0) { _dx = 0; } else if (localPosition.dx > length) { _dx = length; } else { _dx = localPosition.dx; } if (localPosition.dy < 0) { _dy = 0; } else if (localPosition.dy > length) { _dy = length; } else { _dy = localPosition.dy; } return Offset(_dx, _dy); } } class Signature extends CustomPainter { Signature(this.state, this.context); final DrawState state; final BuildContext context; @override void paint(Canvas canvas, Size size) { const strokeWigth = 12.0; final paint = Paint() ..color = Colors.black ..strokeCap = StrokeCap.round ..strokeWidth = strokeWigth; for (final points in state.paintList) { canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromCenter( center: points[0], width: strokeWigth, height: strokeWigth), const Radius.circular(strokeWigth), ), paint, ); for (var i = 0; i < points.length - 1; i++) { canvas.drawLine(points[i], points[i + 1], paint); } } } @override bool shouldRepaint(Signature oldDelegate) => oldDelegate.state.paintList != state.paintList; } dart_controller.dart import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:state_notifier/state_notifier.dart'; part 'draw_controller.freezed.dart'; // ステート @freezed abstract class DrawState with _$DrawState { const factory DrawState({ @Default(<List<Offset>>[]) List<List<Offset>> paintList, @Default(<List<Offset>>[]) List<List<Offset>> undoList, @Default(false) bool isDrag, }) = _DrawState; } // コントローラー final drawController = StateNotifierProvider.autoDispose<DrawController, DrawState>( (ref) => DrawController()); class DrawController extends StateNotifier<DrawState> { DrawController() : super(const DrawState()); bool get canUndo => state.paintList.isNotEmpty; bool get canRedo => state.undoList.isNotEmpty; void undo() { // 描画中か、undoできなかったら何もしない if (state.isDrag || !canUndo) { return; } // paintListの最後を取って、undoListに追加する final _last = state.paintList.last; state = state.copyWith( undoList: List.of(state.undoList)..add(_last), paintList: List.of(state.paintList)..removeLast(), ); } void redo() { // 描画中か、redoできなかったら何もしない if (state.isDrag || !canRedo) { return; } // undoListの最後を取って、paintListに追加する final _last = state.undoList.last; state = state.copyWith( undoList: List.of(state.undoList)..removeLast(), paintList: List.of(state.paintList)..add(_last), ); } void clear() { if (!state.isDrag) { state = state.copyWith(paintList: [], undoList: []); } } void addPaint(Offset startPoint) { if (!state.isDrag) { state = state.copyWith( isDrag: true, paintList: List.of(state.paintList)..add([startPoint]), undoList: const [], ); } } void updatePaint(Offset nextPoint) { if (state.isDrag) { final _paintList = List<List<Offset>>.of(state.paintList); final _offsetList = List<Offset>.of(state.paintList.last)..add(nextPoint); _paintList.last = _offsetList; state = state.copyWith(paintList: _paintList); } } void endPaint() => state = state.copyWith(isDrag: false); }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Mockk]SystemClockをモックする

mockkStatic()を使います。 Test.kt class Test { @Test fun systemClockTest() { mockkStatic(SystemClock::class) every { SystemClock.elapsedRealtime() } returns 100L assertThat(SystemClock.elapsedRealtime()).isEqualTo(100L) } }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む