Created
January 25, 2021 13:23
-
-
Save laggedHero/cc7580ceb69bf65d918f6a5576e08d04 to your computer and use it in GitHub Desktop.
Some crude "pin view" using compose.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import androidx.compose.animation.core.* | |
import androidx.compose.runtime.* | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.composed | |
import androidx.compose.ui.draw.drawWithContent | |
import androidx.compose.ui.geometry.Offset | |
import androidx.compose.ui.geometry.Rect | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.platform.AmbientAnimationClock | |
import androidx.compose.ui.text.TextLayoutResult | |
import androidx.compose.ui.unit.dp | |
/** | |
* This is heavily based (copied) from compose-ui's cursor modifier | |
*/ | |
internal class PinInputTextFieldState { | |
var textLayoutResult: TextLayoutResult? = null | |
var text: String? = null | |
var hasFocus = false | |
} | |
internal fun Modifier.pinCursor( | |
state: PinInputTextFieldState, | |
cursorColor: Color | |
) = composed { | |
val animationClocks = AmbientAnimationClock.current | |
val cursorAlpha = remember(animationClocks) { AnimatedFloatModel(0f, animationClocks) } | |
if (state.hasFocus && cursorColor != Color.Unspecified) { | |
onCommit(cursorColor, state.text) { | |
cursorAlpha.animateTo(0f, anim = cursorAnimationSpec) | |
onDispose { | |
cursorAlpha.snapTo(0f) | |
} | |
} | |
drawWithContent { | |
this.drawContent() | |
val cursorAlphaValue = cursorAlpha.value.coerceIn(0f, 1f) | |
if (cursorAlphaValue != 0f) { | |
val transformedOffset = state.text?.length ?: 0 | |
val cursorRect = state.textLayoutResult?.getCursorRect(transformedOffset) | |
?: Rect(0f, 0f, 0f, 0f) | |
val cursorWidth = DefaultCursorThickness.toPx() | |
val cursorX = (cursorRect.left + cursorWidth / 2) | |
.coerceAtMost(size.width - cursorWidth / 2) | |
drawLine( | |
cursorColor, | |
Offset(cursorX, cursorRect.top), | |
Offset(cursorX, cursorRect.bottom), | |
alpha = cursorAlphaValue, | |
strokeWidth = cursorWidth | |
) | |
} | |
} | |
} else { | |
Modifier | |
} | |
} | |
private class AnimatedFloatModel( | |
initialValue: Float, | |
clock: AnimationClockObservable, | |
visibilityThreshold: Float = Spring.DefaultDisplacementThreshold | |
) : AnimatedFloat(clock, visibilityThreshold) { | |
override var value: Float by mutableStateOf(initialValue, structuralEqualityPolicy()) | |
} | |
private val cursorAnimationSpec: AnimationSpec<Float> | |
get() = infiniteRepeatable( | |
animation = keyframes { | |
durationMillis = 1000 | |
1f at 0 | |
1f at 499 | |
0f at 500 | |
0f at 999 | |
} | |
) | |
private val DefaultCursorThickness = 2.dp |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import androidx.compose.animation.core.* | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.focusable | |
import androidx.compose.foundation.layout.* | |
import androidx.compose.foundation.text.BasicText | |
import androidx.compose.material.MaterialTheme | |
import androidx.compose.runtime.* | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.focus.FocusRequester | |
import androidx.compose.ui.focus.focusRequester | |
import androidx.compose.ui.focus.isFocused | |
import androidx.compose.ui.focus.onFocusChanged | |
import androidx.compose.ui.gesture.tapGestureFilter | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.platform.AmbientDensity | |
import androidx.compose.ui.platform.AmbientFontLoader | |
import androidx.compose.ui.platform.AmbientTextInputService | |
import androidx.compose.ui.text.ParagraphIntrinsics | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.input.* | |
import androidx.compose.ui.text.style.TextAlign | |
import androidx.compose.ui.tooling.preview.Preview | |
import androidx.compose.ui.unit.Dp | |
import androidx.compose.ui.unit.dp | |
@Composable | |
fun PinInput() { | |
val focusRequester = FocusRequester() | |
// Ambient | |
val textInputService = AmbientTextInputService.current | |
val density = AmbientDensity.current | |
val resourceLoader = AmbientFontLoader.current | |
// Could become a single "state" | |
val pinCode = remember { mutableStateOf(Array(5) { "" }) } | |
val (isFocused, setIsFocused) = remember { mutableStateOf(false) } | |
val (inputSessionToken, setInputSessionToken) = remember { mutableStateOf(-1) } | |
val paragraphIntrinsics = ParagraphIntrinsics( | |
text = "0", | |
style = MaterialTheme.typography.h5, | |
density = density, | |
resourceLoader = resourceLoader | |
) | |
val textWidthRaw = paragraphIntrinsics.maxIntrinsicWidth | |
val textWidth = with(density) { textWidthRaw.toDp() + 4.dp } | |
val textStyle = MaterialTheme.typography.h5 | |
.copy(textAlign = TextAlign.Center) | |
val validKeyboardEntries = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "") | |
val onEditCommandWrapper: (List<EditCommand>) -> Unit = { commands -> | |
commands.forEach { | |
when (it) { | |
is CommitTextCommand -> { | |
if (!validKeyboardEntries.contains(it.text)) return@forEach | |
val newPinCode = pinCode.value.clone() | |
val index = newPinCode.indexOf("") | |
if (index > -1 && index < 5) { | |
newPinCode[index] = it.text | |
pinCode.value = newPinCode | |
} | |
} | |
is BackspaceCommand -> { | |
val newPinCode = pinCode.value.clone() | |
val lookUp = newPinCode.indexOf("") | |
val index = if (lookUp == -1) 4 // last pos | |
else lookUp - 1 | |
if (index > -1 && index < 5) { | |
newPinCode[index] = "" | |
pinCode.value = newPinCode | |
} | |
} | |
} | |
} | |
} | |
val onImeActionPerformedWrapper: (ImeAction) -> Unit = { | |
// | |
} | |
val onFocusChanged = Modifier.onFocusChanged { | |
if (isFocused == it.isFocused) return@onFocusChanged | |
setIsFocused(it.isFocused) | |
if (it.isFocused) { | |
val token = textInputService?.startInput( | |
value = TextFieldValue(), | |
imeOptions = ImeOptions.Default.copy(keyboardType = KeyboardType.Number), | |
onEditCommand = onEditCommandWrapper, | |
onImeActionPerformed = onImeActionPerformedWrapper | |
) ?: INVALID_SESSION | |
setInputSessionToken(token) | |
textInputService?.showSoftwareKeyboard(token) | |
} else { | |
textInputService?.stopInput(inputSessionToken) | |
textInputService?.hideSoftwareKeyboard(inputSessionToken) | |
} | |
} | |
val focusRequestTapModifier = Modifier.tapGestureFilter { | |
if (!isFocused) { | |
focusRequester.requestFocus() | |
} else { | |
textInputService?.showSoftwareKeyboard(inputSessionToken) | |
} | |
} | |
Box( | |
modifier = Modifier | |
.wrapContentWidth() | |
.focusRequester(focusRequester) | |
.then(onFocusChanged) | |
.then(focusRequestTapModifier) | |
.focusable(), | |
) { | |
Row( | |
modifier = Modifier | |
.align(Alignment.Center) | |
.padding(start = 56.dp, top = 8.dp, end = 56.dp, bottom = 8.dp) | |
) { | |
PinInputTextField( | |
textWidth, | |
textStyle, | |
pinCode.value[0], | |
isFocused && pinCode.value[0].isEmpty() | |
) | |
Spacer(modifier = Modifier.width(8.dp)) | |
PinInputTextField( | |
textWidth, | |
textStyle, | |
pinCode.value[1], | |
isFocused && pinCode.value[0].isNotEmpty() && pinCode.value[1].isEmpty() | |
) | |
Spacer(modifier = Modifier.width(8.dp)) | |
PinInputTextField( | |
textWidth, | |
textStyle, | |
pinCode.value[2], | |
isFocused && pinCode.value[1].isNotEmpty() && pinCode.value[2].isEmpty() | |
) | |
Spacer(modifier = Modifier.width(8.dp)) | |
PinInputTextField( | |
textWidth, | |
textStyle, | |
pinCode.value[3], | |
isFocused && pinCode.value[2].isNotEmpty() && pinCode.value[3].isEmpty() | |
) | |
Spacer(modifier = Modifier.width(8.dp)) | |
PinInputTextField( | |
textWidth, | |
textStyle, | |
pinCode.value[4], | |
isFocused && pinCode.value[3].isNotEmpty() | |
) | |
} | |
} | |
} | |
@Composable | |
private fun PinInputTextField( | |
textWidth: Dp, | |
textStyle: TextStyle, | |
pinCodePart: String, | |
hasFocus: Boolean | |
) { | |
val state = remember { PinInputTextFieldState() } | |
state.text = pinCodePart | |
state.hasFocus = hasFocus | |
Column( | |
modifier = Modifier | |
.width(textWidth) | |
.pinCursor(state, Color.Black), | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
BasicText( | |
modifier = Modifier | |
.fillMaxWidth(), | |
style = textStyle, | |
text = pinCodePart, | |
onTextLayout = { textLayoutResult -> | |
state.textLayoutResult = textLayoutResult | |
} | |
) | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(2.dp) | |
.background(Color.LightGray) | |
) | |
} | |
} | |
@Preview | |
@Composable | |
fun PinInputPreview() { | |
PinInput() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment