1 /*
<lambda>null2 * Copyright 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.foundation.text.input
18
19 import androidx.compose.foundation.ExperimentalFoundationApi
20 import androidx.compose.foundation.internal.requirePrecondition
21 import androidx.compose.foundation.text.KeyboardOptions
22 import androidx.compose.runtime.Stable
23 import androidx.compose.ui.semantics.SemanticsPropertyReceiver
24 import androidx.compose.ui.semantics.maxTextLength
25 import androidx.compose.ui.text.input.KeyboardCapitalization
26 import androidx.compose.ui.text.intl.Locale
27 import androidx.compose.ui.text.substring
28 import androidx.compose.ui.text.toUpperCase
29
30 /**
31 * A function that is ran after every change made to a [TextFieldState] by user input and can change
32 * or reject that input.
33 *
34 * Input transformations are ran after hardware and software keyboard events, when text is pasted or
35 * dropped into the field, or when an accessibility service changes the text.
36 *
37 * To chain filters together, call [then].
38 *
39 * Prebuilt filters are provided for common filter operations. See:
40 * - [InputTransformation].[maxLength]`()`
41 * - [InputTransformation].[allCaps]`()`
42 *
43 * @sample androidx.compose.foundation.samples.BasicTextFieldCustomInputTransformationSample
44 */
45 @Stable
46 fun interface InputTransformation {
47
48 /**
49 * Optional [KeyboardOptions] that will be used as the default keyboard options for configuring
50 * the IME. The options passed directly to the text field composable will always override this.
51 */
52 val keyboardOptions: KeyboardOptions?
53 get() = null
54
55 /**
56 * Optional semantics configuration that can update certain characteristics of the applied
57 * TextField, e.g. [SemanticsPropertyReceiver.maxTextLength].
58 */
59 fun SemanticsPropertyReceiver.applySemantics() = Unit
60
61 /**
62 * The transform operation. For more information see the documentation on [InputTransformation].
63 *
64 * This function is scoped to [TextFieldBuffer], a buffer that can be changed in-place to alter
65 * or reject the changes or set the selection.
66 *
67 * To reject all changes in the scoped [TextFieldBuffer], call
68 * [revertAllChanges][TextFieldBuffer.revertAllChanges].
69 *
70 * When multiple [InputTransformation]s are linked together, the [transformInput] function of
71 * the first transformation is invoked before the second one. Once the changes are made to
72 * [TextFieldBuffer] by the initial [InputTransformation] in the chain, the same instance of
73 * [TextFieldBuffer] is forwarded to the subsequent transformation in the chain. Note that
74 * [TextFieldBuffer.originalValue] never changes while the buffer is passed along the chain.
75 * This sequence persists until the chain reaches its conclusion.
76 */
77 fun TextFieldBuffer.transformInput()
78
79 companion object : InputTransformation {
80 override fun TextFieldBuffer.transformInput() {
81 // Noop.
82 }
83 }
84 }
85
86 // region Pre-built transformations
87
88 /**
89 * Creates a filter chain that will run [next] after this. Filters are applied sequentially, so any
90 * changes made by this filter will be visible to [next].
91 *
92 * The returned filter will use the [KeyboardOptions] from [next] if non-null, otherwise it will use
93 * the options from this transformation.
94 *
95 * @sample androidx.compose.foundation.samples.BasicTextFieldInputTransformationChainingSample
96 * @param next The [InputTransformation] that will be ran after this one.
97 */
98 @Stable
InputTransformationnull99 fun InputTransformation.then(next: InputTransformation): InputTransformation =
100 FilterChain(this, next)
101
102 /**
103 * Creates an [InputTransformation] from a function that accepts both the current and proposed
104 * [TextFieldCharSequence] and returns the [TextFieldCharSequence] to use for the field.
105 *
106 * [transformation] can return either `current`, `proposed`, or a completely different value.
107 *
108 * The selection or cursor will be updated automatically. For more control of selection implement
109 * [InputTransformation] directly.
110 *
111 * @sample androidx.compose.foundation.samples.BasicTextFieldInputTransformationByValueChooseSample
112 * @sample androidx.compose.foundation.samples.BasicTextFieldInputTransformationByValueReplaceSample
113 */
114 @Stable
115 fun InputTransformation.byValue(
116 transformation: (current: CharSequence, proposed: CharSequence) -> CharSequence
117 ): InputTransformation = this.then(InputTransformationByValue(transformation))
118
119 /**
120 * Returns a [InputTransformation] that forces all text to be uppercase.
121 *
122 * This transformation automatically configures the keyboard to capitalize all characters.
123 *
124 * @param locale The [Locale] in which to perform the case conversion.
125 */
126 @Stable
127 fun InputTransformation.allCaps(locale: Locale): InputTransformation =
128 this.then(AllCapsTransformation(locale))
129
130 /**
131 * Returns [InputTransformation] that rejects input which causes the total length of the text field
132 * to be more than [maxLength] characters.
133 */
134 @Stable
135 fun InputTransformation.maxLength(maxLength: Int): InputTransformation =
136 this.then(MaxLengthFilter(maxLength))
137
138 // endregion
139 // region Transformation implementations
140
141 private class FilterChain(
142 private val first: InputTransformation,
143 private val second: InputTransformation,
144 ) : InputTransformation {
145
146 override val keyboardOptions: KeyboardOptions?
147 get() =
148 second.keyboardOptions?.fillUnspecifiedValuesWith(first.keyboardOptions)
149 ?: first.keyboardOptions
150
151 override fun SemanticsPropertyReceiver.applySemantics() {
152 with(first) { applySemantics() }
153 with(second) { applySemantics() }
154 }
155
156 override fun TextFieldBuffer.transformInput() {
157 with(first) { transformInput() }
158 with(second) { transformInput() }
159 }
160
161 override fun toString(): String = "$first.then($second)"
162
163 override fun equals(other: Any?): Boolean {
164 if (this === other) return true
165 if (other === null) return false
166 if (this::class != other::class) return false
167
168 other as FilterChain
169
170 if (first != other.first) return false
171 if (second != other.second) return false
172 if (keyboardOptions != other.keyboardOptions) return false
173
174 return true
175 }
176
177 override fun hashCode(): Int {
178 var result = first.hashCode()
179 result = 31 * result + second.hashCode()
180 result = 32 * result + keyboardOptions.hashCode()
181 return result
182 }
183 }
184
185 private data class InputTransformationByValue(
186 val transformation: (current: CharSequence, proposed: CharSequence) -> CharSequence
187 ) : InputTransformation {
transformInputnull188 override fun TextFieldBuffer.transformInput() {
189 val proposed = toTextFieldCharSequence()
190 val accepted = transformation(originalValue, proposed)
191 when {
192 // These are reference comparisons – text comparison will be done by setTextIfChanged.
193 accepted === proposed -> return
194 accepted === originalValue -> revertAllChanges()
195 else -> {
196 setTextIfChanged(accepted)
197 }
198 }
199 }
200
toStringnull201 override fun toString(): String = "InputTransformation.byValue(transformation=$transformation)"
202 }
203
204 // This is a very naive implementation for now, not intended to be production-ready.
205 @OptIn(ExperimentalFoundationApi::class)
206 private data class AllCapsTransformation(private val locale: Locale) : InputTransformation {
207 override val keyboardOptions =
208 KeyboardOptions(capitalization = KeyboardCapitalization.Characters)
209
210 override fun TextFieldBuffer.transformInput() {
211 // only update inserted content
212 changes.forEachChange { range, _ ->
213 if (!range.collapsed) {
214 replace(range.min, range.max, asCharSequence().substring(range).toUpperCase(locale))
215 }
216 }
217 }
218
219 override fun toString(): String = "InputTransformation.allCaps(locale=$locale)"
220 }
221
222 // This is a very naive implementation for now, not intended to be production-ready.
223 private data class MaxLengthFilter(private val maxLength: Int) : InputTransformation {
224
225 init {
<lambda>null226 requirePrecondition(maxLength >= 0) { "maxLength must be at least zero" }
227 }
228
applySemanticsnull229 override fun SemanticsPropertyReceiver.applySemantics() {
230 maxTextLength = maxLength
231 }
232
transformInputnull233 override fun TextFieldBuffer.transformInput() {
234 if (length > maxLength) {
235 revertAllChanges()
236 }
237 }
238
toStringnull239 override fun toString(): String {
240 return "InputTransformation.maxLength($maxLength)"
241 }
242 }
243