I need implement a custom ViewGroup
which seems like Apple Watch home screen with bubbles (there's a screenshot below)
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()
}
}
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
}