1 /*
<lambda>null2 * Copyright 2020 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
18
19 import androidx.compose.foundation.gestures.Orientation
20 import androidx.compose.foundation.gestures.ScrollableState
21 import androidx.compose.foundation.gestures.rememberScrollableState
22 import androidx.compose.foundation.gestures.scrollable
23 import androidx.compose.foundation.interaction.MutableInteractionSource
24 import androidx.compose.foundation.layout.offset
25 import androidx.compose.runtime.Stable
26 import androidx.compose.runtime.derivedStateOf
27 import androidx.compose.runtime.getValue
28 import androidx.compose.runtime.mutableFloatStateOf
29 import androidx.compose.runtime.mutableIntStateOf
30 import androidx.compose.runtime.mutableStateOf
31 import androidx.compose.runtime.remember
32 import androidx.compose.runtime.saveable.listSaver
33 import androidx.compose.runtime.setValue
34 import androidx.compose.runtime.structuralEqualityPolicy
35 import androidx.compose.ui.Modifier
36 import androidx.compose.ui.composed
37 import androidx.compose.ui.draw.clipToBounds
38 import androidx.compose.ui.geometry.Rect
39 import androidx.compose.ui.layout.LayoutModifier
40 import androidx.compose.ui.layout.Measurable
41 import androidx.compose.ui.layout.MeasureResult
42 import androidx.compose.ui.layout.MeasureScope
43 import androidx.compose.ui.platform.LocalLayoutDirection
44 import androidx.compose.ui.platform.debugInspectorInfo
45 import androidx.compose.ui.text.TextLayoutResult
46 import androidx.compose.ui.text.TextRange
47 import androidx.compose.ui.text.input.TextFieldValue
48 import androidx.compose.ui.text.input.TransformedText
49 import androidx.compose.ui.text.input.VisualTransformation
50 import androidx.compose.ui.unit.Constraints
51 import androidx.compose.ui.unit.Density
52 import androidx.compose.ui.unit.LayoutDirection
53 import androidx.compose.ui.util.fastRoundToInt
54 import kotlin.math.min
55
56 // Scrollable
57 internal fun Modifier.textFieldScrollable(
58 scrollerPosition: TextFieldScrollerPosition,
59 interactionSource: MutableInteractionSource? = null,
60 enabled: Boolean = true
61 ) =
62 composed(
63 inspectorInfo =
64 debugInspectorInfo {
65 name = "textFieldScrollable"
66 properties["scrollerPosition"] = scrollerPosition
67 properties["interactionSource"] = interactionSource
68 properties["enabled"] = enabled
69 }
70 ) {
71 // do not reverse direction only in case of RTL in horizontal orientation
72 val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
73 val reverseDirection = scrollerPosition.orientation == Orientation.Vertical || !rtl
deltanull74 val scrollableState = rememberScrollableState { delta ->
75 val newOffset = scrollerPosition.offset + delta
76 val consumedDelta =
77 when {
78 newOffset > scrollerPosition.maximum ->
79 scrollerPosition.maximum - scrollerPosition.offset
80 newOffset < 0f -> -scrollerPosition.offset
81 else -> delta
82 }
83 scrollerPosition.offset += consumedDelta
84 consumedDelta
85 }
86 // TODO: b/255557085 remove when / if rememberScrollableState exposes lambda parameters for
87 // setting these
88 val wrappedScrollableState =
89 remember(scrollableState, scrollerPosition) {
90 object : ScrollableState by scrollableState {
<lambda>null91 override val canScrollForward by derivedStateOf {
92 scrollerPosition.offset < scrollerPosition.maximum
93 }
<lambda>null94 override val canScrollBackward by derivedStateOf {
95 scrollerPosition.offset > 0f
96 }
97 }
98 }
99 val scroll =
100 Modifier.scrollable(
101 orientation = scrollerPosition.orientation,
102 reverseDirection = reverseDirection,
103 state = wrappedScrollableState,
104 interactionSource = interactionSource,
105 enabled = enabled && scrollerPosition.maximum != 0f
106 )
107 scroll
108 }
109
110 // Layout
111 // Expect/actual is needed due to a different implementation in uikit
textFieldScrollnull112 internal expect fun Modifier.textFieldScroll(
113 scrollerPosition: TextFieldScrollerPosition,
114 textFieldValue: TextFieldValue,
115 visualTransformation: VisualTransformation,
116 textLayoutResultProvider: () -> TextLayoutResultProxy?
117 ): Modifier
118
119 internal fun Modifier.defaultTextFieldScroll(
120 scrollerPosition: TextFieldScrollerPosition,
121 textFieldValue: TextFieldValue,
122 visualTransformation: VisualTransformation,
123 textLayoutResultProvider: () -> TextLayoutResultProxy?
124 ): Modifier {
125 val orientation = scrollerPosition.orientation
126 val cursorOffset = scrollerPosition.getOffsetToFollow(textFieldValue.selection)
127 scrollerPosition.previousSelection = textFieldValue.selection
128
129 val transformedText = visualTransformation.filterWithValidation(textFieldValue.annotatedString)
130
131 val layout =
132 when (orientation) {
133 Orientation.Vertical ->
134 VerticalScrollLayoutModifier(
135 scrollerPosition,
136 cursorOffset,
137 transformedText,
138 textLayoutResultProvider
139 )
140 Orientation.Horizontal ->
141 HorizontalScrollLayoutModifier(
142 scrollerPosition,
143 cursorOffset,
144 transformedText,
145 textLayoutResultProvider
146 )
147 }
148 return this.clipToBounds().then(layout)
149 }
150
151 private data class VerticalScrollLayoutModifier(
152 val scrollerPosition: TextFieldScrollerPosition,
153 val cursorOffset: Int,
154 val transformedText: TransformedText,
155 val textLayoutResultProvider: () -> TextLayoutResultProxy?
156 ) : LayoutModifier {
measurenull157 override fun MeasureScope.measure(
158 measurable: Measurable,
159 constraints: Constraints
160 ): MeasureResult {
161 val childConstraints = constraints.copy(maxHeight = Constraints.Infinity)
162 val placeable = measurable.measure(childConstraints)
163 val height = min(placeable.height, constraints.maxHeight)
164
165 return layout(placeable.width, height) {
166 val cursorRect =
167 getCursorRectInScroller(
168 cursorOffset = cursorOffset,
169 transformedText = transformedText,
170 textLayoutResult = textLayoutResultProvider()?.value,
171 rtl = false,
172 textFieldWidth = placeable.width
173 )
174
175 scrollerPosition.update(
176 orientation = Orientation.Vertical,
177 cursorRect = cursorRect,
178 containerSize = height,
179 textFieldSize = placeable.height
180 )
181
182 val offset = -scrollerPosition.offset
183 placeable.placeRelative(0, offset.fastRoundToInt())
184 }
185 }
186 }
187
188 private data class HorizontalScrollLayoutModifier(
189 val scrollerPosition: TextFieldScrollerPosition,
190 val cursorOffset: Int,
191 val transformedText: TransformedText,
192 val textLayoutResultProvider: () -> TextLayoutResultProxy?
193 ) : LayoutModifier {
measurenull194 override fun MeasureScope.measure(
195 measurable: Measurable,
196 constraints: Constraints
197 ): MeasureResult {
198 // If the maxIntrinsicWidth of the children is already smaller than the constraint, pass
199 // the original constraints so that the children has more information to determine its
200 // size.
201 val maxIntrinsicWidth = measurable.maxIntrinsicWidth(constraints.maxHeight)
202 val childConstraints =
203 if (maxIntrinsicWidth < constraints.maxWidth) {
204 constraints
205 } else {
206 constraints.copy(maxWidth = Constraints.Infinity)
207 }
208 val placeable = measurable.measure(childConstraints)
209 val width = min(placeable.width, constraints.maxWidth)
210
211 return layout(width, placeable.height) {
212 val cursorRect =
213 getCursorRectInScroller(
214 cursorOffset = cursorOffset,
215 transformedText = transformedText,
216 textLayoutResult = textLayoutResultProvider()?.value,
217 rtl = layoutDirection == LayoutDirection.Rtl,
218 textFieldWidth = placeable.width
219 )
220
221 scrollerPosition.update(
222 orientation = Orientation.Horizontal,
223 cursorRect = cursorRect,
224 containerSize = width,
225 textFieldSize = placeable.width
226 )
227
228 val offset = -scrollerPosition.offset
229 placeable.placeRelative(offset.fastRoundToInt(), 0)
230 }
231 }
232 }
233
getCursorRectInScrollernull234 private fun Density.getCursorRectInScroller(
235 cursorOffset: Int,
236 transformedText: TransformedText,
237 textLayoutResult: TextLayoutResult?,
238 rtl: Boolean,
239 textFieldWidth: Int
240 ): Rect {
241 val cursorRect =
242 textLayoutResult?.getCursorRect(
243 transformedText.offsetMapping.originalToTransformed(cursorOffset)
244 ) ?: Rect.Zero
245 val thickness = DefaultCursorThickness.roundToPx()
246
247 val cursorLeft =
248 if (rtl) {
249 textFieldWidth - cursorRect.left - thickness
250 } else {
251 cursorRect.left
252 }
253
254 val cursorRight =
255 if (rtl) {
256 textFieldWidth - cursorRect.left
257 } else {
258 cursorRect.left + thickness
259 }
260 return cursorRect.copy(left = cursorLeft, right = cursorRight)
261 }
262
263 @Stable
264 internal class TextFieldScrollerPosition(
265 initialOrientation: Orientation,
266 initial: Float = 0f,
267 ) {
268
269 /*@VisibleForTesting*/
270 constructor() : this(Orientation.Vertical)
271
272 /**
273 * Left or top offset. Takes values from 0 to [maximum]. Taken with the opposite sign defines
274 * the x or y position of the text field in the horizontal or vertical scroller container
275 * correspondingly.
276 */
277 var offset by mutableFloatStateOf(initial)
278
279 /**
280 * Maximum length by which the text field can be scrolled. Defined as a difference in size
281 * between the scroller container and the text field.
282 */
283 var maximum by mutableFloatStateOf(0f)
284 private set
285
286 /** Size of the visible part, on the scrollable axis, in pixels. */
287 var viewportSize by mutableIntStateOf(0)
288 private set
289
290 /**
291 * Keeps the cursor position before a new symbol has been typed or the text field has been
292 * dragged. We check it to understand if the [offset] needs to be updated.
293 */
294 private var previousCursorRect: Rect = Rect.Zero
295
296 /**
297 * Keeps the previous selection data in TextFieldValue in order to identify what has changed in
298 * the new selection, and decide which selection offset (start, end) to follow.
299 */
300 var previousSelection: TextRange = TextRange.Zero
301
302 var orientation by mutableStateOf(initialOrientation, structuralEqualityPolicy())
303
updatenull304 fun update(orientation: Orientation, cursorRect: Rect, containerSize: Int, textFieldSize: Int) {
305 val difference = (textFieldSize - containerSize).toFloat()
306 maximum = difference
307
308 if (
309 cursorRect.left != previousCursorRect.left || cursorRect.top != previousCursorRect.top
310 ) {
311 val vertical = orientation == Orientation.Vertical
312 val cursorStart = if (vertical) cursorRect.top else cursorRect.left
313 val cursorEnd = if (vertical) cursorRect.bottom else cursorRect.right
314 coerceOffset(cursorStart, cursorEnd, containerSize)
315 previousCursorRect = cursorRect
316 }
317 offset = offset.coerceIn(0f, difference)
318 viewportSize = containerSize
319 }
320
321 /*@VisibleForTesting*/
coerceOffsetnull322 internal fun coerceOffset(cursorStart: Float, cursorEnd: Float, containerSize: Int) {
323 val startVisibleBound = offset
324 val endVisibleBound = startVisibleBound + containerSize
325 val offsetDifference =
326 when {
327 // make bottom/end of the cursor visible
328 //
329 // text box
330 // +----------------------+
331 // | |
332 // | |
333 // | cursor |
334 // | | |
335 // +-------------|--------+
336 // |
337 //
338 cursorEnd > endVisibleBound -> cursorEnd - endVisibleBound
339
340 // in rare cases when there's not enough space to fit the whole cursor, prioritise
341 // the bottom/end of the cursor
342 //
343 // cursor
344 // text box |
345 // +-------------|--------+
346 // | | |
347 // +-------------|--------+
348 // |
349 //
350 cursorStart < startVisibleBound && cursorEnd - cursorStart > containerSize ->
351 cursorEnd - endVisibleBound
352
353 // make top/start of the cursor visible if there's enough space to fit the whole
354 // cursor
355 //
356 // cursor
357 // text box |
358 // +--------------|-------+
359 // | | |
360 // | |
361 // | |
362 // | |
363 // +----------------------+
364 //
365 cursorStart < startVisibleBound && cursorEnd - cursorStart <= containerSize ->
366 cursorStart - startVisibleBound
367
368 // otherwise keep current offset
369 else -> 0f
370 }
371 offset += offsetDifference
372 }
373
getOffsetToFollownull374 fun getOffsetToFollow(selection: TextRange): Int {
375 return when {
376 selection.start != previousSelection.start -> selection.start
377 selection.end != previousSelection.end -> selection.end
378 else -> selection.min
379 }
380 }
381
382 companion object {
383 val Saver =
384 listSaver<TextFieldScrollerPosition, Any>(
<lambda>null385 save = { listOf(it.offset, it.orientation == Orientation.Vertical) },
restorednull386 restore = { restored ->
387 TextFieldScrollerPosition(
388 if (restored[1] as Boolean) Orientation.Vertical
389 else Orientation.Horizontal,
390 restored[0] as Float
391 )
392 }
393 )
394 }
395 }
396