All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.androidx.compose.foundation.text.input.InputTransformation.kt Maven / Gradle / Ivy

Go to download

Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers

There is a newer version: 1.8.0-alpha01
Show newest version
/*
 * Copyright 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.foundation.text.input

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Stable
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.maxTextLength
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.substring
import androidx.compose.ui.text.toUpperCase
import kotlin.jvm.JvmName

/**
 * A function that is ran after every change made to a [TextFieldState] by user input and can change
 * or reject that input.
 *
 * Input transformations are ran after hardware and software keyboard events, when text is pasted or
 * dropped into the field, or when an accessibility service changes the text.
 *
 * To chain filters together, call [then].
 *
 * Prebuilt filters are provided for common filter operations. See:
 *  - [InputTransformation].[maxLength]`()`
 *  - [InputTransformation].[allCaps]`()`
 *
 * @sample androidx.compose.foundation.samples.BasicTextFieldCustomInputTransformationSample
 */
@Stable
fun interface InputTransformation {

    /**
     * Optional [KeyboardOptions] that will be used as the default keyboard options for configuring
     * the IME. The options passed directly to the text field composable will always override this.
     */
    val keyboardOptions: KeyboardOptions? get() = null

    /**
     * Optional semantics configuration that can update certain characteristics of the applied
     * TextField, e.g. [SemanticsPropertyReceiver.maxTextLength].
     */
    fun SemanticsPropertyReceiver.applySemantics() = Unit

    /**
     * The transform operation. For more information see the documentation on [InputTransformation].
     *
     * This function is scoped to [TextFieldBuffer], a buffer that can be changed in-place to alter
     * or reject the changes or set the selection.
     *
     * To reject all changes in the scoped [TextFieldBuffer], call
     * [revertAllChanges][TextFieldBuffer.revertAllChanges].
     *
     * When multiple [InputTransformation]s are linked together, the [transformInput] function of
     * the first transformation is invoked before the second one. Once the changes are made to
     * [TextFieldBuffer] by the initial [InputTransformation] in the chain, the same instance of
     * [TextFieldBuffer] is forwarded to the subsequent transformation in the chain. Note that
     * [TextFieldBuffer.originalValue] never changes while the buffer is passed along the chain.
     * This sequence persists until the chain reaches its conclusion.
     */
    fun TextFieldBuffer.transformInput()

    companion object : InputTransformation {
        override fun TextFieldBuffer.transformInput() {
            // Noop.
        }
    }
}

// region Pre-built transformations

/**
 * Creates a filter chain that will run [next] after this. Filters are applied sequentially, so any
 * changes made by this filter will be visible to [next].
 *
 * The returned filter will use the [KeyboardOptions] from [next] if non-null, otherwise it will
 * use the options from this transformation.
 *
 * @sample androidx.compose.foundation.samples.BasicTextFieldInputTransformationChainingSample
 *
 * @param next The [InputTransformation] that will be ran after this one.
 */
@Stable
fun InputTransformation.then(next: InputTransformation): InputTransformation =
    FilterChain(this, next)

/**
 * Creates an [InputTransformation] from a function that accepts both the current and proposed
 * [TextFieldCharSequence] and returns the [TextFieldCharSequence] to use for the field.
 *
 * [transformation] can return either `current`, `proposed`, or a completely different value.
 *
 * The selection or cursor will be updated automatically. For more control of selection
 * implement [InputTransformation] directly.
 *
 * @sample androidx.compose.foundation.samples.BasicTextFieldInputTransformationByValueChooseSample
 * @sample androidx.compose.foundation.samples.BasicTextFieldInputTransformationByValueReplaceSample
 */
@Stable
fun InputTransformation.byValue(
    transformation: (
        current: CharSequence,
        proposed: CharSequence
    ) -> CharSequence
): InputTransformation = this.then(InputTransformationByValue(transformation))

/**
 * Returns a [InputTransformation] that forces all text to be uppercase.
 *
 * This transformation automatically configures the keyboard to capitalize all characters.
 *
 * @param locale The [Locale] in which to perform the case conversion.
 */
@Stable
fun InputTransformation.allCaps(locale: Locale): InputTransformation =
    this.then(AllCapsTransformation(locale))

/**
 * Returns [InputTransformation] that rejects input which causes the total length of the text field
 * to be more than [maxLength] characters.
 */
@Stable
fun InputTransformation.maxLength(maxLength: Int): InputTransformation =
    this.then(MaxLengthFilter(maxLength))

// endregion
// region Transformation implementations

private class FilterChain(
    private val first: InputTransformation,
    private val second: InputTransformation,
) : InputTransformation {

    override val keyboardOptions: KeyboardOptions?
        get() = second.keyboardOptions?.fillUnspecifiedValuesWith(first.keyboardOptions)
            ?: first.keyboardOptions

    override fun SemanticsPropertyReceiver.applySemantics() {
        with(first) { applySemantics() }
        with(second) { applySemantics() }
    }

    override fun TextFieldBuffer.transformInput() {
        with(first) { transformInput() }
        with(second) { transformInput() }
    }

    override fun toString(): String = "$first.then($second)"

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other === null) return false
        if (this::class != other::class) return false

        other as FilterChain

        if (first != other.first) return false
        if (second != other.second) return false
        if (keyboardOptions != other.keyboardOptions) return false

        return true
    }

    override fun hashCode(): Int {
        var result = first.hashCode()
        result = 31 * result + second.hashCode()
        result = 32 * result + keyboardOptions.hashCode()
        return result
    }
}

private data class InputTransformationByValue(
    val transformation: (
        current: CharSequence,
        proposed: CharSequence
    ) -> CharSequence
) : InputTransformation {
    override fun TextFieldBuffer.transformInput() {
        val proposed = toTextFieldCharSequence()
        val accepted = transformation(originalValue, proposed)
        when {
            // These are reference comparisons – text comparison will be done by setTextIfChanged.
            accepted === proposed -> return
            accepted === originalValue -> revertAllChanges()
            else -> {
                setTextIfChanged(accepted)
            }
        }
    }

    override fun toString(): String = "InputTransformation.byValue(transformation=$transformation)"
}

// This is a very naive implementation for now, not intended to be production-ready.
@OptIn(ExperimentalFoundationApi::class)
private data class AllCapsTransformation(private val locale: Locale) : InputTransformation {
    override val keyboardOptions = KeyboardOptions(
        capitalization = KeyboardCapitalization.Characters
    )

    override fun TextFieldBuffer.transformInput() {
        // only update inserted content
        changes.forEachChange { range, _ ->
            if (!range.collapsed) {
                replace(
                    range.min,
                    range.max,
                    asCharSequence().substring(range).toUpperCase(locale)
                )
            }
        }
    }

    override fun toString(): String = "InputTransformation.allCaps(locale=$locale)"
}

// This is a very naive implementation for now, not intended to be production-ready.
private data class MaxLengthFilter(
    private val maxLength: Int
) : InputTransformation {

    init {
        require(maxLength >= 0) { "maxLength must be at least zero, was $maxLength" }
    }

    override fun SemanticsPropertyReceiver.applySemantics() {
        maxTextLength = maxLength
    }

    override fun TextFieldBuffer.transformInput() {
        if (length > maxLength) {
            revertAllChanges()
        }
    }

    override fun toString(): String {
        return "InputTransformation.maxLength($maxLength)"
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy