Jetpack Compose - Scroll to focused composable in Column
Asked Answered
S

5

26

I have UI like this:

val scrollState = rememberScrollState()
        Column(
            modifier = Modifier
                .fillMaxSize(1F)
                .padding(horizontal = 16.dp)
                .verticalScroll(scrollState)
        ) {

            TextField(...)
 // multiple textfields
             TextField(
                        //...
                        modifier = Modifier.focusOrder(countryFocus).onFocusChanged {
                            if(it == FocusState.Active) {
                               // scroll to this textfield
                            }
                        },
                    )
         }

I have multiple TextFields in this column and when one of them is focused I want to scroll Column to it. There is a method in scrollState scrollState.smoothScrollTo(0f) but I have no idea how to get a focused TextField position.

Update:

It seems that I've found a working solution. I've used onGloballyPositioned and it works. But I'm not sure if it the best way of solving this.

var scrollToPosition = 0.0F

TextField(
   modifier = Modifier
    .focusOrder(countryFocus)
    .onGloballyPositioned { coordinates ->
        scrollToPosition = scrollState.value + coordinates.positionInRoot().y
    }
    .onFocusChanged {
    if (it == FocusState.Active) {
        scope.launch {
            scrollState.smoothScrollTo(scrollToPosition)
        }
    }
}
)
Special answered 17/2, 2021 at 20:56 Comment(0)
F
8

Also you can use BringIntoViewRequester

//
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val coroutineScope = rememberCoroutineScope()
//--------
TextField( ..., modifier = Modifier.bringIntoViewRequester(bringIntoViewRequester)
.onFocusEvent {
                        if (it.isFocused) {
                            coroutineScope.launch {
                                bringIntoViewRequester.bringIntoView()
                            }
                        }
                    }
Florri answered 20/10, 2022 at 5:25 Comment(1)
this is the right way πŸ‘† – Katzir
N
3

There is a new thing in compose called RelocationRequester. That solved the problem for me. I have something like this inside of my custom TextField.

val focused = source.collectIsFocusedAsState()
val relocationRequester = remember { RelocationRequester() }
val ime = LocalWindowInsets.current.ime
if (ime.isVisible && focused.value) {
    relocationRequester.bringIntoView()
}
Neap answered 7/6, 2021 at 21:29 Comment(4)
This is not working for me. I mean View is brought into the screen height(frame) but it is still behind ime. I think the space behind ime is still considered as visible space. Am i missing something here? – Ronnironnica
bringIntoView() is behaving weird.. working on One Plus and Pixel devices, but not working on Redmi devices – Hershelhershell
Can you be more clear in your answer and explain what 'source' is – Minestrone
Adding awaitFrame() before bringIntoView() solved my problem. – Actionable
S
1

It seems that using LazyColumn and LazyListState.animateScrollToItem() instead of Column could be a good option for your case.

Reference: https://developer.android.com/jetpack/compose/lists#control-scroll-position

By the way, thank you for the information about onGloballyPositioned() modifier. I was finding a solution for normal Column case. It saved me a lot of time!

Swam answered 3/6, 2021 at 0:46 Comment(0)
D
0

Here's some code I used to make sure that the fields in my form were not cut off by the keyboard:

From: stack overflow - detect when keyboard is open

enum class Keyboard {
Opened, Closed
}

@Composable
fun keyboardAsState(): State<Keyboard> {
    val keyboardState = remember { mutableStateOf(Keyboard.Closed) }
    val view = LocalView.current
    DisposableEffect(view) {
    val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
        val rect = Rect()
        view.getWindowVisibleDisplayFrame(rect)
        val screenHeight = view.rootView.height
        val keypadHeight = screenHeight - rect.bottom
        keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
            Keyboard.Opened
        } else {
            Keyboard.Closed
        }
    }
    view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)

    onDispose {
        
    view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
         }
    }

    return keyboardState
}

and then in my composable:

val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()

val isKeyboardOpen by keyboardAsState()

if (isKeyboardOpen == Keyboard.Opened) {
    val view = LocalView.current
    val screenHeight = view.rootView.height
    scope.launch { scrollState.scrollTo((screenHeight * 2)) }
}

Surface(modifier = Modifier
    .fillMaxHeight()
    .verticalScroll(scrollState),
    
)  {
  //Rest of your Composables, Columns, Rows, TextFields, Buttons

  //add this so the screen can scroll up and keyboard cannot cover the form fields - Important!
 /*************************************************/
  if (isKeyboardOpen == Keyboard.Opened) {
     Spacer(modifier = Modifier.height(140.dp))
  }

}

Hope it helps someone. I was using:

val bringIntoViewRequester = remember { BringIntoViewRequester() }
val scope = rememberCoroutineScope()
val view = LocalView.current
DisposableEffect(view) {
    val listener = ViewTreeObserver.OnGlobalLayoutListener {
        scope.launch { bringIntoViewRequester.bringIntoView() }
    }
    view.viewTreeObserver.addOnGlobalLayoutListener(listener)
    onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(listener) }
}
Surface(modifier.bringIntoViewRequester(bringIntoViewRequester)) {

///////////rest of my composables
}

But this did not work.

Diastase answered 22/6, 2022 at 18:4 Comment(0)
B
0

For regular Column, you can use the following extension function:

Here is full Gist source code

fun Modifier.bringIntoView(
    scrollState: ScrollState
): Modifier = composed {
    var scrollToPosition by remember {
        mutableStateOf(0f)
    }
    val coroutineScope = rememberCoroutineScope()
    this
        .onGloballyPositioned { coordinates ->
            scrollToPosition = scrollState.value + coordinates.positionInRoot().y
        }
        .onFocusEvent {
            if (it.isFocused) {
                coroutineScope.launch {
                    scrollState.animateScrollTo(scrollToPosition.toInt())
                }
            }
        }
}
Blacken answered 19/10, 2023 at 19:59 Comment(0)

© 2022 - 2024 β€” McMap. All rights reserved.