Search code examples
androidandroid-layoutapple-watchandroid-viewgroup

Implementing of Apple watch board UI for Android


I need implement a custom ViewGroup which seems like Apple Watch home screen with bubbles (there's a screenshot below)

enter image description here

The ViewGroup has to be scrollable in both directions and its children have to change their scale in depend of how close to the center they are. I tried to implement this using RecyclerView with custom LayoutManager, where the first element is located in the center, and the others are around. But I stuck with it, when I try to achieve dynamically adding/removing of items during scrolling. So, I need any help. Maybe somebody knows about existing solutions or has some clues. I will be glad to any help! I've also attached the source of my custom LayoutManager

class AppleWatchLayoutManager : RecyclerView.LayoutManager() {

    private val viewCache = SparseArray<View>()

    override fun generateDefaultLayoutParams() = RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT,
        RecyclerView.LayoutParams.WRAP_CONTENT)

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        detachAndScrapAttachedViews(recycler)
        fill(recycler)
    }

    override fun canScrollVertically() = true
    override fun canScrollHorizontally() = true

    override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int {
        val delta = scrollVerticallyInternal(dy)
        offsetChildrenVertical(-delta)
        return delta
    }

    override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int {
        val delta = scrollHorizontallyInternal(dx)
        offsetChildrenHorizontal(-delta)
        return delta
    }

    private fun fill(recycler: RecyclerView.Recycler) {

        val anchorView = findAchorView()
        viewCache.clear()

        val childCount = childCount
        for (i in 0 until childCount) {
            val view = getChildAt(i)
            if (view != null) {
                val position = getPosition(view)
                viewCache.put(position, view)
            }
        }

        var cacheSize = viewCache.size()
        for (i in 0 until cacheSize) {
            detachView(viewCache.valueAt(i))
        }

        fill(recycler, anchorView)

        cacheSize = viewCache.size()
        for (i in 0 until cacheSize) {
            recycler.recycleView(viewCache.valueAt(i))
        }
    }

    private fun fill(recycler: RecyclerView.Recycler, anchorView: View?) {
        val anchorPosition = if (anchorView != null) getPosition(anchorView) else 0
        val xOffset = if (anchorView != null) {
            getDecoratedLeft(anchorView) + (getDecoratedMeasuredWidth(anchorView) / 2) - (width / 2)
        } else {
            0
        }
        val yOffset = if (anchorView != null) {
            getDecoratedTop(anchorView) + (getDecoratedMeasuredHeight(anchorView) / 2) - (height / 2)
        } else {
            0
        }
        var filling = true
        var round = 0
        var position = anchorPosition
        var scale = 0.9f
        while (filling && position < itemCount) {
            val sector = if (round == 0) 0.0 else 2 * PI / (6 * round)
            var angle = 0.0
            if (round == 0) {
                filling = fillRound(recycler, round, position, angle, xOffset, yOffset, 1f)
                position++
            } else {
                for (i in 1..(6 * round)) {
                    filling = filling && fillRound(recycler, round, position, angle, xOffset, yOffset, scale)
                    angle += sector
                    position++
                }
            }
            round++
            scale -= 0.1f
        }
    }


    private fun scrollHorizontallyInternal(dx: Int): Int {
        if (childCount == 0) {
            return 0
        }

        val currentRound = getCurrentRound()
        val roundsCount = getRoundsCount()
        if (currentRound == roundsCount) {
            val mostLeftChild = findMostLeftChild()
            val mostRightChild = findMostRightChild()

            if (mostLeftChild != null && mostRightChild != null) {
                val viewSpan = getDecoratedRight(mostRightChild) - getDecoratedLeft(mostLeftChild)
                if (viewSpan <= width) {
                    return 0
                }
            } else {
                return 0
            }
        }

        var delta = 0

        if (dx < 0) {
            val mostLeftChild = findMostLeftChild()
            delta = if (mostLeftChild != null) {
                Math.max(getDecoratedLeft(mostLeftChild), dx)
            } else dx
        } else if (dx > 0) {
            val mostRightChild = findMostRightChild()
            delta = if (mostRightChild != null) {
                Math.min(getDecoratedRight(mostRightChild) - width, dx)
            } else dx
        }
        return delta
    }

    private fun scrollVerticallyInternal(dy: Int): Int {
        if (childCount == 0) {
            return 0
        }

        // All views fit on screen
        if (childCount == itemCount) {
            val highestChild = findHighestChild()
            val lowestChild = findLowestChild()

            if (highestChild != null && lowestChild != null) {
                val viewSpan = getDecoratedBottom(lowestChild) - getDecoratedTop(highestChild)
                if (viewSpan <= height) {
                    return 0
                }
            } else {
                return 0
            }
        }
        var delta = 0

        // content moves down
        if (dy < 0) {
            val highestChild = findHighestChild()
            delta = if (highestChild != null) {
                Math.max(getDecoratedTop(highestChild), dy)
            } else dy
        } else if (dy > 0) {
            val lowestChild = findLowestChild()
            delta = if (lowestChild != null) {
                Math.min(getDecoratedBottom(lowestChild) - height, dy)
            } else dy
        }
        return delta
    }

    private fun fillRound(recycler: RecyclerView.Recycler, round: Int, element: Int, angle: Double,
                      xOffset: Int, yOffset: Int, scale: Float): Boolean {
        var view = viewCache[element]
        if (view == null) {
            view = recycler.getViewForPosition(element)
            addView(view)
            measureChildWithMargins(view, 0, 0)
            val x = getDecoratedMeasuredWidth(view) * round * Math.cos(angle) + width / 2 + xOffset
            val y = getDecoratedMeasuredHeight(view) * round * Math.sin(angle) + height / 2 + yOffset

            val left = (x - getDecoratedMeasuredWidth(view) / 2).toInt()
            val top = (y - getDecoratedMeasuredHeight(view) / 2).toInt()
            val right = (x + getDecoratedMeasuredWidth(view) / 2).toInt()
            val bottom = (y + getDecoratedMeasuredHeight(view) / 2).toInt()
            layoutDecorated(view, left, top, right, bottom)
        } else {
            attachView(view)
            viewCache.remove(element)
        }

        val decoratedBottom = getDecoratedBottom(view)
        val decoratedTop = getDecoratedTop(view)
        val decoratedLeft = getDecoratedLeft(view)
        val decoratedRight = getDecoratedRight(view)

        return (decoratedBottom <= height && decoratedTop >= 0) ||
            (decoratedLeft >= 0 && decoratedRight <= width)
    }

    private fun getRoundsCount(): Int {
        var itemCount = itemCount
        var rounds = 0
        var coeff = 1
        while (itemCount > 0) {
            rounds++
            itemCount -= 6 * coeff
            coeff++
        }
        return rounds
    }

    private fun getRoundByPosittion(position: Int): Int {
        if (position == 0) {
            return 0
        }
        if (position >= itemCount) {
            throw IndexOutOfBoundsException("There's less items in RecyclerView than given position. Position is $position")
        }
        var elementsCount = 1
        var round = 0
        var coeff = 1
        do {
            round++
            elementsCount += 6 * coeff
            coeff++
        } while (position > elementsCount)
        return round
    }

    private fun getCurrentRound(): Int {
        var childCount = childCount
        if (childCount <= 1) {
            return 0
        } else if (childCount <= 7) {
            return 1
        }
        childCount --
        var round = 1
        var coeff = 1
        while (childCount > 0) {
            childCount -= 6 * coeff
            coeff++
            round++
        }
        return round
    }

    private fun findHighestChild(): View? {
        val childCount = childCount
        if (childCount > 0) {
            var highestView = getChildAt(0)
            for (i in 0 until childCount) {
                val view = getChildAt(i)
                if (view != null) {
                    val top = getDecoratedTop(view)
                    val highestViewTop = getDecoratedTop(highestView!!)
                    if (top < highestViewTop) {
                        highestView = view
                    }
                }
            }
            return highestView
        }
        return null
    }

    private fun findLowestChild(): View? {
        val childCount = childCount
        if (childCount > 0) {
            var lowestView = getChildAt(0)
            for (i in 0 until childCount) {
                val view = getChildAt(i)
                if (view != null) {
                    val bottom = getDecoratedBottom(view)
                    val lowestViewBottom = getDecoratedBottom(lowestView!!)
                    if (bottom > lowestViewBottom) {
                        lowestView = view
                    }
                }
            }
            return lowestView
        }
        return null
    }

    private fun findMostLeftChild(): View? {
        val childCount = childCount
        if (childCount > 0) {
            var mostLeftView = getChildAt(0)
            for (i in 0 until childCount) {
                val view = getChildAt(i)
                if (view != null) {
                    val left = getDecoratedLeft(view)
                    val mostLeftViewLeft = getDecoratedLeft(mostLeftView!!)
                    if (left < mostLeftViewLeft) {
                        mostLeftView = view
                    }
                }
            }
            return mostLeftView
        }
        return null
    }

    private fun findMostRightChild(): View? {
        val childCount = childCount
        if (childCount > 0) {
            var mostRightView = getChildAt(0)
            for (i in 0 until childCount) {
                val view = getChildAt(i)
                if (view != null) {
                    val right = getDecoratedRight(view)
                    val mostRightViewRight = getDecoratedRight(mostRightView!!)
                    if (right > mostRightViewRight) {
                        mostRightView = view
                    }
                }
            }
            return mostRightView
        }
        return null
    }

    private fun findAchorView(): View? {
        val childCount = childCount
        val centerX = width / 2
        val centerY = height / 2

        var anchorView: View? = null
        var minDistance = Int.MAX_VALUE

        for (i in 0 until childCount) {
            val view = getChildAt(i)
            if (view != null) {
                val distance = distanceBetweenCenters(view, centerX, centerY)
                if (distance < minDistance) {
                    minDistance = distance
                    anchorView = view
                }
            }
        }
        return anchorView
    }

    private fun distanceBetweenCenters(view: View, centerX: Int, centerY: Int): Int {
        val viewCenterX = getDecoratedLeft(view) + getDecoratedMeasuredWidth(view) / 2
        val viewCenterY = getDecoratedTop(view) + getDecoratedMeasuredHeight(view) / 2

        return sqrt((centerX - viewCenterX) * (centerX - viewCenterX) * 1.0 + (centerY - viewCenterY) * (centerY - viewCenterY)).toInt()
    }
}

Solution

  • Finally, I've got a solution. Before the layout of elements I just define a collection of special models, which contains information about its position on the screen. Then, during scrolling I modify the collection, lay out elements which is on the screen now and recycle items, which is not on the screen. But there's a drawback: I have to pass an item size to constructor of manager to provide correct filling of children. The itemSize should be the same as defined in XML of item. Maybe the solution is not perfect, but it works fine for me. Here's the code of LayoutManager.

    class BubbleLayoutManager(private val itemSize: Int) : RecyclerView.LayoutManager() {
    
        private val children = mutableListOf<Child>()
    
        override fun generateDefaultLayoutParams() = RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT,
            RecyclerView.LayoutParams.WRAP_CONTENT)
    
        override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
            fillChildren()
            detachAndScrapAttachedViews(recycler)
            fillView(recycler)
        }
    
        override fun canScrollVertically() = true
        override fun canScrollHorizontally() = true
    
        override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int {
            val delta = scrollVerticallyInternal(dy)
            offsetChildren(yOffset = -delta)
            offsetChildrenVertical(-delta)
            fillAndRecycle(recycler)
            return dy
        }
    
        override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int {
            val delta = scrollHorizontallyInternal(dx)
            offsetChildren(xOffset = -delta)
            offsetChildrenHorizontal(-delta)
            fillAndRecycle(recycler)
            return dx
        }
    
        private fun fillAndRecycle(recycler: RecyclerView.Recycler) {
            val itemCount = itemCount
            for (i in 0 until itemCount) {
                if (i < children.size) {
                    val child = children[i]
                    val childRect = childRect(child)
                    val alreadyDrawn = alreadyDrawn(child)
                    if (!alreadyDrawn && fitOnScreen(childRect)) {
                        val view = recycler.getViewForPosition(i)
                        addView(view)
                        measureChildWithMargins(view, 0, 0)
                        layoutDecorated(view, childRect.left, childRect.top, childRect.right, childRect.bottom)
                    }
                }
            }
            recycleViews(recycler)
            updateScales()
        }
    
        private fun recycleViews(recycler: RecyclerView.Recycler) {
            val childCount = childCount
            for (i in 0 until childCount) {
                val view = getChildAt(i)
                if (view != null && !fitOnScreen(view)) {
                    detachView(view)
                    recycler.recycleView(view)
                }
            }
        }
    
        private fun fillView(recycler: RecyclerView.Recycler) {
            val itemCount = itemCount
            for (i in 0 until itemCount) {
                if (i < children.size) {
                    val childRect = childRect(children[i])
                    if (fitOnScreen(childRect)) {
                        val view = recycler.getViewForPosition(i)
                        addView(view)
                        measureChildWithMargins(view, 0, 0)
                        layoutDecorated(view, childRect.left, childRect.top, childRect.right, childRect.bottom)
                    }
                }
            }
            updateScales()
        }
    
        private fun scrollVerticallyInternal(dy: Int): Int {
            if (childCount == 0) {
                return 0
            }
    
            val highestChild = children.minBy { it.y }
            val lowestChild = children.maxBy { it.y }
    
            if (highestChild != null && lowestChild != null) {
                if (lowestChild.y + itemSize / 2 <= height && highestChild.y - itemSize / 2 >= 0) {
                    return 0
                }
            } else {
                return 0
            }
    
            var delta = 0
            if (dy < 0) {
                delta = if (highestChild.y - itemSize / 2 < 0) {
                    max(highestChild.y - itemSize / 2, dy)
                } else 0
            } else if (dy > 0) {
                delta = if (lowestChild.y + itemSize / 2 > height) {
                    min(lowestChild.y + itemSize / 2 - height, dy)
                } else 0
            }
            return delta
        }
    
        private fun scrollHorizontallyInternal(dx: Int): Int {
            if (childCount == 0) {
                return 0
            }
    
            val mostLeftChild = children.minBy { it.x }
            val mostRightChild = children.maxBy { it.x }
    
            if (mostLeftChild != null && mostRightChild != null) {
                if (mostLeftChild.x - itemSize / 2 >= 0 && mostRightChild.x + itemSize / 2 <= width) {
                    return 0
                }
            } else {
                return 0
            }
    
            var delta = 0
            if (dx < 0) {
                delta = if (mostLeftChild.x - itemSize / 2 < 0) {
                    max(mostLeftChild.x - itemSize / 2, dx)
                } else 0
            } else if (dx > 0) {
                delta = if (mostRightChild.x + itemSize / 2 > width) {
                    min(mostRightChild.x + itemSize / 2 - width, dx)
                } else 0
            }
            return delta
        }
    
        private fun offsetChildren(xOffset: Int = 0, yOffset: Int = 0) {
            children.forEach { it.offset(xOffset, yOffset) }
        }
    
        private fun updateScales() {
            val centerX = width / 2
            val centerY = height / 2
            val distanceMap = sortedMapOf<Int, MutableList<Int>>()
            val childCount = childCount
            for (i in 0 until childCount) {
                val view = getChildAt(i)
                if (view != null) {
                    val distance = distance(centerX, centerY, view.x.toInt() + view.width / 2, view.y.toInt() + view.height / 2)
                    val positions = distanceMap.getOrPut(distance) { mutableListOf() }
                    positions.add(i)
                }
            }
            var scale = 1f
            distanceMap.keys.forEach { key ->
                val positions = distanceMap[key]
                if (positions != null) {
                    for (position in positions) {
                        val view = getChildAt(position)
                        if (view != null) {
                            view.scaleX = scale
                            view.scaleY = scale
                        }
                    }
                }
                scale *= 0.95f
            }
        }
    
        private fun distance(x1: Int, y1: Int, x2: Int, y2: Int) = sqrt(((x2 - x1) * (x2 - x1)).toFloat() + ((y2 - y1) * (y2 - y1)).toFloat()).toInt()
    
        private fun childRect(child: Child): Rect {
            val left = child.x - itemSize / 2
            val top = child.y - itemSize / 2
            val right = left + itemSize
            val bottom = top + itemSize
            return Rect(left, top, right, bottom)
        }
    
        private fun fillChildren() {
            children.clear()
            val centerX = width / 2
            val centerY = height / 2
    
            val itemCount = itemCount
            if (itemCount > 0) {
                children.add(Child(centerX, centerY))
                if (itemCount > 1) {
                    for (i in 1 until itemCount) {
                        fillChildrenRelative(children[i - 1], itemCount)
                    }
                }
            }
        }
    
        private fun fillChildrenRelative(anchorChild: Child, itemCount: Int) {
            var i = 0
            var direction = Direction.initial()
            while (i < 4 && children.size < itemCount) {
                val childX = anchorChild.x + (itemSize / 2) * direction.widthMultiplier
                val childY = anchorChild.y + itemSize * direction.heightMultiplier
                if (!hasChild(childX, childY)) {
                    children.add(Child(childX, childY))
                }
                direction = direction.next()
                i++
            }
        }
    
        private fun hasChild(x: Int, y: Int) = children.any { it.x == x && it.y == y }
    
        private fun fitOnScreen(view: View) = fitOnScreen(getViewRect(view))
    
        private fun getViewRect(view: View) = Rect(
                getDecoratedLeft(view),
                getDecoratedTop(view),
                getDecoratedRight(view),
                getDecoratedBottom(view)
        )
    
        private fun fitOnScreen(rect: Rect): Boolean = rect.intersects(0, 0, width, height)
    
        private fun alreadyDrawn(child: Child): Boolean {
            val rect = childRect(child)
            val childCount = childCount
            for (i in 0 until childCount) {
                val view = getChildAt(i)
                if (view != null) {
                    val viewRect = getViewRect(view)
                    if (viewRect.intersects(rect.left, rect.top, rect.right, rect.bottom)) {
                        return true
                    }
                }
            }
            return false
        }
    
        private data class Child(
                var x: Int,
                var y: Int
        ) {
    
            fun offset(xOffset: Int = 0, yOffset: Int = 0) {
                x += xOffset
                y += yOffset
            }
        }
    }
    
    
    // Direction.kt
    internal sealed class Direction(
            val widthMultiplier: Int, val heightMultiplier: Int
    ) {
        companion object {
            internal fun initial(): Direction = LeftTop
        }
    }
    
    internal object LeftTop : Direction(-1, -1)
    internal object RightTop : Direction(1, -1)
    internal object LeftBottom : Direction(-1, 1)
    internal object RightBottom : Direction(1, 1)
    
    internal fun Direction.next() = when (this) {
        is LeftTop -> RightTop
        is RightTop -> LeftBottom
        is LeftBottom -> RightBottom
        is RightBottom -> LeftTop
    }