'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 1:[1]

You need to create your own custom ViewGroup subclass which will handle all the item resizing and scrolling.

No need to recycle views as you have a relatively small number of them.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 urgentx