Search code examples
androidandroid-recyclerviewleanback

How do I move grid items on Android TV?


I'm working on a grid interface (using VerticalGridSupportFragment) for Android TV and I'm looking for a way to allow users to move around items in the grid.

The idea is that the grid contains a number of TV channels and that the users should be able to change the order of the TV channels (sort / reorder). My proposed solution is to select a channel by clicking it. The channel then becomes "sticky" allowing you to move it around. When you're happy with the position, you click on the channel again, confirming its new position.

The obvious solution is to do something along the lines of this:

getVerticalGridView()?.let { 
    it.setOnChildSelectedListener { _, _, position, _ ->
        // Move the item in the previous position to the new position
        adapter.move(oldPosition, position)

        // Set old position to currently selected position.
        oldPosition = position
    }
}

fun VerticalGridSupportFragment.getVerticalGridView(): VerticalGridView? {
    return VerticalGridSupportFragment::class.java.getDeclaredField("mGridViewHolder")?.let {
        it.isAccessible = true
        return (it.get(this) as VerticalGridPresenter.ViewHolder).gridView
    }
}

The problem with this is that adapter.move() causes another child selected event.

I've tried to circumvent this issue by temporarily removing the selection listener and instead keep a ObjectAdapter.DataObserver to notify me of onItemMoved() events, in which I set the selected position and once again set a selection listener.

This doesn't seem to work fully either.

It's not possible to use ItemTouchHelper as that was designed for touch purposes and not using a remote like we do on Android TV.

The official Android TV launcher app is doing something similar to what I need when you rearrange app shortcuts on the homescreen, but I can't think of a way to make it work.


Solution

  • Found a solution, which also appears to be what Google is using for the Android TV launcher.

    In short: Create a custom VerticalGridView and override its focusSearch() method to determine how move / swap items.

    Something similar to this:

    class EditableVerticalGridView @JvmOverloads constructor(context: Context,
                                                             attrs: AttributeSet? = null,
                                                             defStyle: Int = 0) :
            VerticalGridView(context, attrs, defStyle) {
    
        override fun focusSearch(focused: View, direction: Int): View {
            return if (focused.isSelected) {
                swapItemsIfNeeded(focused, direction)
            } else super.focusSearch(focused, direction)
        }
    
        private fun swapItemsIfNeeded(focused: View, direction: Int): View {
            val position = getChildAdapterPosition(focused)
            if (!itemAnimator.isRunning) {
                if (canMoveInDirection(position, direction)) {
                    when (direction) {
                        FOCUS_LEFT -> moveChannel(position, position - 1)
                        FOCUS_UP -> moveChannel(position, position - NUM_COLUMN)
                        FOCUS_RIGHT -> moveChannel(position, position + 1)
                        FOCUS_DOWN -> moveChannel(position, position + NUM_COLUMN)
                    }
                }
            }
            return focused
        }
    
        private fun canMoveInDirection(position: Int, direction: Int): Boolean {
            when (direction) {
                FOCUS_LEFT -> {
                    return position % NUM_COLUMN > 0
                }
                FOCUS_UP -> {
                    return position - NUM_COLUMN >= 0
                }
                FOCUS_RIGHT -> {
                    return !(position % NUM_COLUMN >= (NUM_COLUMN - 1) ||
                            position >= adapter.itemCount - 1)
                }
                FOCUS_DOWN -> {
                    return position + NUM_COLUMN <= adapter.itemCount - 1
                }
                else -> {
                    return false
                }
            }
        }
    
        private fun moveChannel(fromPosition: Int, toPosition: Int) {
            (adapter as AllowedChannelAdapter).moveChannel(fromPosition, toPosition)
        }
    
        companion object {
    
            private const val NUM_COLUMN: Int = 6
    
        }
    
    }
    

    ... and the moveChannel() function:

    fun moveChannel(from: Int, to: Int) {
            var offset = 1
            if (from >= 0 && from <= channelItems.size - 1 && to >= 0 && to <= channelItems.size - 1) {
                val fromItem = channelItems[from]
                channelItems[from] = channelItems[to]
                channelItems[to] = fromItem
                notifyItemMoved(from, to)
    
                val positionDifference = to - from
                if (Math.abs(positionDifference) > 1) {
                    if (positionDifference > 0) {
                        offset = -1
                    }
                    notifyItemMoved(to + offset, from)
                }
            }
        }