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