Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Selectable Lazy Column, Tree #42

Merged
merged 3 commits into from
Jun 6, 2023
Merged

Selectable Lazy Column, Tree #42

merged 3 commits into from
Jun 6, 2023

Conversation

fscarponi
Copy link
Collaborator

@fscarponi fscarponi commented Apr 26, 2023

OBSOLETE: SCROLL TO NEXT COMMENT

New components (SelectableLazyColumn, Tree) can be found in the foundation package.

Int UI Demo

Those elements have a default implementation of KeyBindingScopedActions, and support focus and selection.

Standalone Int UI sample org/jetbrains/jewel/samples/standalone/expui/Main.kt has been updated to show a tree.

Tree Element Definition

For better usability, we decided to split the element into nodes and leaves, obv a leaf cannot have children.

sealed interface Element<T> {

    val data: T
    val depth: Int
    val parent: Element<T>?
    val childIndex: Int
    var next: Element<T>?
    var previous: Element<T>?
    val id: Any
}

class Leaf<T>(
    overrides..
) : Element<T>

class Node<T>(
    overrides...
    private val childrenGenerator: (parent: Node<T>) -> List<Element<T>>,
) : Element<T> 

Tree APIs for ID

Commonly we expect an ID of type Any for elements, but nothing binds an element to a tree, so we decide to create an ID strategy where the function idPath() can identify an element inside a specific Tree (useful to support persistence interactions, if needed)

fun path() = buildList {
    var next: Element<T>? = this@Element
    while (next != null) {
        add(next)
        next = next.parent
    }
}.reversed()

fun idPath() = path().map { it.id }

As we expect Tree has no cognition about it is represented, this duty will be done by the TreeState, in detail in our implementation we decided to represent the Tree as an ordered list of elements, where the kinship is represented graphically with the depth of every single element... the pair <position, depth> can explain without any doubt the Tree structure.

TreeState

class TreeState(val delegate: SelectableLazyListState) : FocusableState by delegate {

    internal var tree: Tree<*> by Delegates.notNull()
    fun attachTree(tree: Tree<*>) {
        this.tree = tree
        refreshFlattenTree()
    }

    var flattenedTree by mutableStateOf<List<Tree.Element<*>>>(emptyList())  //this is the state to represent!

    val isFocused -> delegate.isFocused
    val selectedElementsMap -> delegate.selectedItemIndexes

    fun openCloseNodes()
}

The TreeState defines all APIs that are strictly bonded to a Tree interaction and delegates the other generic action to the subComponentState like selection->SelectableState and focus->FocusState

SubComponent: SelectableLazyList

It is made to hold and manage the concept of selection, every Item in the list should be provided with an id (ANY) and the specification that is Selectable or NotSelectable.
Note the focus interaction is Delegate to FocusableLazyColumnState

sealed interface SelectableKey {

    val key: Any
    @JvmInline value class Selectable(override val key: Any) : SelectableKey
    @JvmInline value class NotSelectable(override val key: Any) : SelectableKey
}

This indication will be used in the SelectableLazyListState for all selection interactions like mouse/tap events and keybinding events

class SelectableLazyListState(
    internal val delegate: FocusableLazyListState = FocusableLazyListState(),
    val isMultiSelectionAllowed: Boolean = true
) : FocusableState by delegate {

    internal val selectedIdsMap = mutableStateMapOf<SelectableKey, Int>()
    internal var keys = emptyList<SelectableKey>()

    //edit selection apis
    fun selectNext()....
}

The binding/refresh from state and list elements will be done by the component via attachKeys():

internal fun attachKeys(keys: List<SelectableKey>, uiId: String) {
      if (this.uiId == null) {
          this.uiId = uiId
      } else {
          require(this.uiId == uiId) {
              "Do not attach the same ${this::class.simpleName} to different SelectableLazyColumns."
          }
      }
      this.keys = keys
      keys.forEachIndexed { index, key ->
          selectedIdsMap.computeIfPresent(key) { _, _ -> index }
      }
  }

The mapping of the items will be done with the same pattern as LazyColumn, with SelectableLazyListScope that forward this mapping downside to FocusableLazyColumn:

interface SelectableLazyListScope {

    fun item(
        key: Any,
        contentType: Any? = null,
        focusable: Boolean = true,
        selectable: Boolean = true,
        content: @Composable SelectableLazyItemScope.() -> Unit
    )

    fun items(
        count: Int,
        key: (index: Int) -> Any,
        contentType: (index: Int) -> Any? = { null },
        focusable: (index: Int) -> Boolean = { true },
        selectable: (index: Int) -> Boolean = { true },
        itemContent: @Composable SelectableLazyItemScope.(index: Int) -> Unit
    )

    fun stickyHeader(
        key: Any,
        contentType: Any? = null,
        focusable: Boolean = false,
        selectable: Boolean = false,
        content: @Composable SelectableLazyItemScope.() -> Unit
    )
}

SubComponent: FocusableLazyColumn
The last layer between Selection and the LazyColumn is made by FocusableLazyList and his state holder FocusableLazyListState,

class FocusableLazyListState(val delegate: LazyListState = LazyListState()) : FocusableState, ScrollableState by delegate {
    internal val lastFocusedKeyState: MutableState<LastFocusedKeyContainer> = mutableStateOf(LastFocusedKeyContainer.NotSet)
    internal val lastFocusedIndexState: MutableState<Int?> = mutableStateOf(null)


    override suspend fun focusItem(itemIndex: Int, animateScroll: Boolean, scrollOffset: Int) {
        val visibleRange = visibleItemsRange.drop(2).dropLast(4)

        if (itemIndex !in visibleRange && visibleRange.isNotEmpty()) {
            when {
                itemIndex < visibleRange.first() -> delegate.scrollToItem(max(0, itemIndex - 2), animateScroll, scrollOffset)
                itemIndex > visibleRange.last() -> {
                    val indexOfFirstVisibleElement = itemIndex - visibleRange.size
                    delegate.scrollToItem(
                        min(delegate.layoutInfo.totalItemsCount - 1, indexOfFirstVisibleElement - 1),
                        animateScroll,
                        scrollOffset
                    )
                }
            }
        }

        focusVisibleItem(itemIndex)
    }
}

Like selectableLazyColumn, we are mapping the item that will be forwarded to LazyColumnScope, for every item a FocusRequester will be associated once!

interface FocusableLazyListScope {

    fun item(
        key: Any,
        contentType: Any? = null,
        focusable: Boolean = true,
        content: @Composable FocusableLazyItemScope.() -> Unit
    )

    fun items(
        count: Int,
        key: ((index: Int) -> Any)? = null,
        contentType: (index: Int) -> Any? = { null },
        focusable: (index: Int) -> Boolean = { true },
        itemContent: @Composable FocusableLazyItemScope.(index: Int) -> Unit
    )

    fun stickyHeader(
        key: Any,
        contentType: Any? = null,
        focusable: Boolean = false,
        content: @Composable FocusableLazyItemScope.() -> Unit
    )
}
       

Final Components APIs

@Composable
fun FocusableLazyColumn(
    modifier: Modifier = Modifier,
    verticalScroll: Boolean = false,
    state: FocusableLazyListState = rememberFocusableLazyListState(),
    contentPadding: PaddingValues = PaddingValues(0.dp),
    reverseLayout: Boolean = false,
    verticalArrangement: Arrangement.Vertical =
        if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    onKeyPressed: KeyEvent.(Int) -> Boolean = { _ -> false },
    content: FocusableLazyListScope.() -> Unit
)

@Composable 
fun SelectableLazyColumn(
    modifier: Modifier = Modifier,
    verticalScroll: Boolean = false,
    state: SelectableLazyListState = rememberSelectableLazyListState(),
    contentPadding: PaddingValues = PaddingValues(0.dp),
    reverseLayout: Boolean = false,
    verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    keyActions: KeyBindingScopedActions = DefaultSelectableLazyColumnKeyActions(state),
    content: SelectableLazyListScope.() -> Unit
) {
    LaunchedEffect(keyActions) {
        state.attachKeybindings(keyActions)
    }

    val container = remember(content) { SelectableLazyListScopeDelegate(state).apply(content) }
    val uiId = remember { UUID.randomUUID().toString() }
    LaunchedEffect(container) { state.attachKeys(container.keys, uiId) }
    FocusableLazyColumn(...args..)
}

@Composable
fun <T> TreeView(
    modifier: Modifier = Modifier,
    tree: Tree<T>,
    treeState: TreeState = rememberTreeState(),
    onElementClick: (Tree.Element<T>) -> Unit = { Log.d("click") },
    onElementDoubleClick: (Tree.Element<T>) -> Unit = { Log.d("double click") },
    keyActions: KeyBindingScopedActions = DefaultTreeViewKeyActions(treeState),
    selectionFocusedBackgroundColor: Color,
    selectionBackgroundColor: Color,
    platformDoubleClickDelay: Long = 500L,
    deepIndentDP: Int = 20,
    arrowContent: @Composable (isOpen: Boolean) -> Unit,
    elementContent: @Composable (Tree.Element<T>) -> Unit
) {
    LaunchedEffect(tree) {
        treeState.attachTree(tree)
    }

    val flattenTree = treeState.flattenedTree

    val scope = rememberCoroutineScope()

    val isTreeFocused = treeState.delegate.lastFocusedIndex != null
    val selectionColor by animateColorAsState(if (isTreeFocused) selectionFocusedBackgroundColor else selectionBackgroundColor)
    SelectableLazyColumn(...args...)
}

@fscarponi fscarponi requested a review from rock3r April 26, 2023 14:31
@rock3r rock3r requested a review from jimgoog April 26, 2023 14:37
Copy link

@andkulikov andkulikov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey! I am the owner of LazyColumn in Google. Jim asked to take a look on this commit so I left a few comments

@fscarponi fscarponi marked this pull request as draft May 5, 2023 13:38
@fscarponi
Copy link
Collaborator Author

fscarponi commented May 19, 2023

The model and its implementation have been revised following the received feedback.

in details:

  1. FocusableLazyColumn has been removed, his logic has been moved to Selectable Lazy column.
  2. The SelectableLazyListScope will provide to content composition "isFocused" and "isSelected"
  3. The new implementation should allow us to execute the user-provided content lambda directly inside the content lambda passed to LazyColumn
  4. The KeyBinding and PointerEvent interaction have been refactored in order to be more user-friendly
  5. Custom scrollbar has been replaced by the default one.

@fscarponi fscarponi marked this pull request as ready for review May 19, 2023 08:40
@rock3r
Copy link
Collaborator

rock3r commented May 26, 2023

@andkulikov @ralstondasilva could you take another look to see if this is good to go?

@rock3r
Copy link
Collaborator

rock3r commented Jun 5, 2023

@andkulikov @ralstondasilva ping, this is blocking folks at JetBrains 🙏

Copy link

@andkulikov andkulikov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, left a few comments

key for item can not be null

LaunchedEffect switched to DisposableEffect when possible

PointerInput handling is now bound to a significant key
Copy link
Collaborator

@rock3r rock3r left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:shipit: we can always come back for further changes later if we get more feedback but we can at least unblock this

@fscarponi fscarponi merged commit 817adc0 into main Jun 6, 2023
@fscarponi fscarponi deleted the fabrizio.scarponi/tree branch June 6, 2023 12:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants