作成したもの
Play store
Github
参考にした記事
Layout
-
keyboard_view (ConstraintLayout)
- 日本語のテンキーキーボードのレイアウト
-
keyboard_kigou_view (ConstraintLayout)
- 絵文字や顔文字を表示するキーボードのレイアウト
-
suggestion_recyclerView (RecyclerView)
- 変換の候補を表示する RecyclerView
ConstraintLayout の chainStyle に spread を選択する事で layout のネストを少なくした。
キーマップ
キーマップを こちらの記事 を参考にして作成した。上下左右フリック文字を取得出来るようにした
sealed class TenKeyInfo{
object Null : TenKeyInfo()
abstract class TenKeyTapFlickInfo : TenKeyInfo() {
abstract val tap: Char
abstract val flickLeft: Char
abstract val flickTop: Char
abstract val flickRight: Char
abstract val flickBottom: Char
}
object KeyAJapanese : TenKeyTapFlickInfo() {
override val tap: Char
get() = 'あ'
override val flickLeft: Char
get() = 'い'
override val flickTop: Char
get() = 'う'
override val flickRight: Char
get() = 'え'
override val flickBottom: Char
get() = 'お'
}
object KeyKAJapanese : TenKeyTapFlickInfo() {
override val tap: Char
get() = 'か'
override val flickLeft: Char
get() = 'き'
override val flickTop: Char
get() = 'く'
override val flickRight: Char
get() = 'け'
override val flickBottom: Char
get() = 'こ'
}
//以下略
}
文字入力に使用したロジック
1. setOnTouchListener を使用しタップやフリックを検知する
/** mainView.keyboardView.keySmallLetter is AppCompatImageButton, others are AppCompatButton **/
val keyList = listOf<Any>(
mainView.keyboardView.key1,
mainView.keyboardView.key2,
//以下略
)
keyList.forEach {
if (it is AppCompatButton){
it.setOnTouchListener { v, event ->
when(event.action and MotionEvent.ACTION_MASK){
MotionEvent.ACTION_DOWN ->{
firstXPoint = event.rawX
firstYPoint = event.rawY
currentTenKeyId = v.id
return@setOnTouchListener false
}
MotionEvent.ACTION_UP ->{
val finalX = event.rawX
val finalY = event.rawY
val distanceX = (finalX - firstXPoint)
val distanceY = (finalY - firstYPoint)
val keyInfoJapanese = tenKeyMap.getTenKeyInfoJapanese(currentTenKeyId)
if (abs(distanceX) < 100 && abs(distanceY) < 100){
if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
/** Tap タップ **/
}
currentTenKeyId = 0
return@setOnTouchListener false
}
if (abs(distanceX) > abs(distanceY)) {
if (firstXPoint < finalX) {
if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
/** Flick Right 右フリック **/
}
}else {
if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
/** Flick Left 左フリック **/
}
}
}else {
if (firstYPoint < finalY) {
if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
/** Flick Down 下フリック **/
}
}else{
if (keyInfoJapanese is TenKeyInfo.TenKeyTapFlickInfo){
/** Flick Up 上フリック **/
}
}
}
currentTenKeyId = 0
return@setOnTouchListener false
}
MotionEvent.ACTION_MOVE ->{
val finalX = event.rawX
val finalY = event.rawY
val distanceX = (finalX - firstXPoint)
val distanceY = (finalY - firstYPoint)
if (abs(distanceX) < 100 && abs(distanceY) < 100){
/** Tap タップ **/
return@setOnTouchListener false
}
if (abs(distanceX) > abs(distanceY)) {
if (firstXPoint < finalX) {
/** Flick Right 右フリック **/
}else{
/** Flick Left 左フリック **/
}
}else {
if (firstYPoint < finalY){
/** Flick Down 下フリック **/
}else{
/** Flick Up 上フリック **/
}
}
return@setOnTouchListener false
}
else -> return@setOnTouchListener true
}
}
it.setOnLongClickListener { v ->
/** Display PopupWindows around long clicked button **/
/** ロングクリックした場合、PopupWindows をボタンの周りに表示する **/
false
}
}
}
2. collectLatest を使用する
_inputString という名前の MutableStateFlow を用意して onCreateInputView() 内で collectLatestする。InputConnection.setComposingText(CharSequence text, int newCursorPosition) で文字を入力する
private val _inputString = MutableStateFlow("")
override fun onCreateInputView(): View? {
val ctx = ContextThemeWrapper(this, R.style.Theme_MarkdownKeyboard)
mainLayoutBinding = MainLayoutBinding.inflate(LayoutInflater.from(ctx))
return mainLayoutBinding?.root.apply {
mainLayoutBinding?.let { mainView ->
scope.launch {
withContext(imeIoDispatcher){
_inputString.asStateFlow().collectLatest { inputString ->
if (inputString.isNotBlank()) {
/** SpannableString で入力された文字の周りの色を設定する **/
val spannableString = SpannableString(inputString)
spannableString.apply {
setSpan(BackgroundColorSpan(getColor(R.color.green)),0,inputString.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
/** SpannableString を setComposingText でセットする **/
currentInputConnection?.setComposingText(spannableString,1)
} else {
/** inputString が Empty 場合の処理 **/
}
}
}
}
}
}
}
カーソルの位置を取得する
1. onUpdateCursorInfo
を override する
onStartInput で InputConnection.requestCursorUpdates(int cursorUpdateMode) を呼び出す。
CURSOR_UPDATE_MONITOR のフラッグを使用してカーソルが移動する度に onUpdateCursorAnchorInfo が呼ばれる。
private var mComposingTextPosition = -1
private var selectionEndtPosition = -1
override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
super.onStartInput(attribute, restarting)
currentInputConnection.requestCursorUpdates(InputConnection.CURSOR_UPDATE_MONITOR)
}
override fun onUpdateCursorAnchorInfo(cursorAnchorInfo: CursorAnchorInfo?) {
super.onUpdateCursorAnchorInfo(cursorAnchorInfo)
cursorAnchorInfo?.let { info ->
selectionEndtPosition = info.selectionEnd
mComposingTextPosition = info.composingTextStart
}
}
2. ExtractedText を使用する
onUpdateCursorAnchorInfo
をサポートしていない View を発見した。(2023 年 Android FireFox アプリ内の WebView での Google 検索)
その為、ExtractedText の startOffset + selectionEnd でカーソルの位置を取得する。
Enter Key の処理
Facebook の Messager や Message 等、 Enter Key で改行や独自の処理を行いたい場合がある。
1. InputType に対応する Sealed Class を作成する
sealed class InputTypeForIME{
object None: InputTypeForIME()
object Text: InputTypeForIME()
object TextAutoComplete: InputTypeForIME()
object TextMultiLine: InputTypeForIME()
object TextImeMultiLine: InputTypeForIME()
object TextWebEditText: InputTypeForIME()
object TextSearchView: InputTypeForIME()
object Number: InputTypeForIME()
object Phone: InputTypeForIME()
object Date: InputTypeForIME()
/** 以下省略 **/
}
2. 作成した Sealed class を返す method を作成する
InputType はこちらのページを参考にした
fun getCurrentInputTypeForIME(
inputType: Int
): InputTypeForIME{
return when(inputType and InputType.TYPE_MASK_CLASS){
InputType.TYPE_CLASS_TEXT ->{
when(inputType){
InputType.TYPE_TEXT_VARIATION_NORMAL -> InputTypeForIME.Text
InputType.TYPE_TEXT_FLAG_MULTI_LINE -> InputTypeForIME.TextMultiLine
/**
* 180225 : Twitter Tweet & Messenger
* 147457 : Facebook Messenger
* */
InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE,180225, 147457 -> InputTypeForIME.TextImeMultiLine
InputType.TYPE_TEXT_VARIATION_PASSWORD, 129, 225,16545, 209 -> InputTypeForIME.TextPassword
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD -> InputTypeForIME.TextVisiblePassword
/**
* 524465 : Twitter (X) Search View
* **/
524465 -> InputTypeForIME.TextSearchView
/** 以下省略 **/
else -> InputTypeForIME.Text
}
}
InputType.TYPE_CLASS_NUMBER ->{
when(inputType){
InputType.TYPE_NUMBER_VARIATION_NORMAL -> InputTypeForIME.Number
InputType.TYPE_NUMBER_FLAG_SIGNED -> InputTypeForIME.NumberSigned
InputType.TYPE_NUMBER_FLAG_DECIMAL -> InputTypeForIME.NumberDecimal
InputType.TYPE_NUMBER_VARIATION_PASSWORD -> InputTypeForIME.NumberPassword
180225 -> InputTypeForIME.TextImeMultiLine
else -> InputTypeForIME.Number
}
}
InputType.TYPE_CLASS_PHONE -> {
when(inputType){
180225 -> InputTypeForIME.TextImeMultiLine
else -> InputTypeForIME.Phone
}
}
InputType.TYPE_CLASS_DATETIME ->{
when(inputType){
InputType.TYPE_DATETIME_VARIATION_NORMAL -> InputTypeForIME.Datetime
InputType.TYPE_DATETIME_VARIATION_DATE -> InputTypeForIME.Date
InputType.TYPE_DATETIME_VARIATION_TIME -> InputTypeForIME.Time
180225 -> InputTypeForIME.TextImeMultiLine
else -> InputTypeForIME.Datetime
}
}
InputType.TYPE_NULL -> InputTypeForIME.None
else -> InputTypeForIME.None
}
}
3. onStartInput
で作成した Sealed Class を EditorInfo.inputType
を引数にして取得する
EditorInfo.TYPE_NULL の場合 raw key event の処理をする。引用
private var currentInputType: InputTypeForIME = InputTypeForIME.Text
override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
super.onStartInput(attribute, restarting)
attribute?.apply {
currentInputType = getCurrentInputTypeForIME(inputType)
when(currentInputType){
InputTypeForIME.Text,
InputTypeForIME.TextAutoComplete,
/** 以下省略 **/
-> {
/** layout type Japanese **/
}
InputTypeForIME.TextEditTextInBookingTDBank,
InputTypeForIME.TextUri,
InputTypeForIME.TextPostalAddress,
InputTypeForIME.TextEmailAddress,
InputTypeForIME.TextWebEmailAddress,
InputTypeForIME.TextPassword,
InputTypeForIME.TextVisiblePassword,
InputTypeForIME.TextWebPassword,
->{
/** layout type English **/
}
InputTypeForIME.None, InputTypeForIME.TextNotCursorUpdate ->{
/** text type null **/
}
InputTypeForIME.Number,
InputTypeForIME.NumberDecimal,
InputTypeForIME.NumberPassword,
InputTypeForIME.NumberSigned,
InputTypeForIME.Phone,
InputTypeForIME.Date,
InputTypeForIME.Datetime,
InputTypeForIME.Time, -> {
/** layout type Number **/
}
}
}
}
4. Enter Key の処理
private fun setEnterKeyPress(){
when(currentInputType){
InputTypeForIME.TextMultiLine,
InputTypeForIME.TextImeMultiLine ->{
currentInputConnection?.commitText("\n",1)
}
InputTypeForIME.None,
InputTypeForIME.Text,
/** 以下省略 **/
-> {
currentInputConnection?.apply {
sendKeyEvent(
KeyEvent(
KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER
)
)
sendKeyEvent(
KeyEvent(
KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER
)
)
}
}
InputTypeForIME.Number,
InputTypeForIME.NumberPassword,
InputTypeForIME.Phone,
InputTypeForIME.Date, -> {
currentInputConnection?.performEditorAction(EditorInfo.IME_ACTION_DONE)
}
InputTypeForIME.TextSearchView ->{
currentInputConnection?.performEditorAction(EditorInfo.IME_ACTION_SEARCH)
}
}
}
その他
Google Chrome 等、検索アプリで英語入力すると自動で候補が入力される。
例えば you と打つと youtube となる。
この状態で 新たに currentInputConnection.setComposingText
のメソッドを使用すると currentInputConnection.finishComposingText()
の処理がされてしまう。
その為 onUpdateCursorAnchorInfo
で ComposeingText が null の場合、_inputString を初期化する処置をとった。
override fun onUpdateCursorAnchorInfo(cursorAnchorInfo: CursorAnchorInfo?) {
super.onUpdateCursorAnchorInfo(cursorAnchorInfo)
cursorAnchorInfo?.let { info ->
if (info.composingText == null && _inputString.value.isNotEmpty()){
if (currentInputType == InputTypeForIME.TextWebSearchView || currentInputType == InputTypeForIME.TextWebSearchViewFireFox) {
_inputString.update { EMPTY_STRING }
}
}
}
}
最後に
かな漢字変換に OpenWnn を使用した。最終的には独自のかな漢字変換器を実装して搭載したい。