The effect

Let’s look at the implementation first:

As you can see, the circular menu is somewhat similar to the implementation of the wheel effect, the wheel effect is more common, such as setting the time when the wheel effect is often used. So in fact, through the performance of the circular menu can be regarded as a round wheel, is a variant of the wheel realization.

There are two clear ways to achieve the circular menu. One is a custom View, which needs to deal with the drawing in the scrolling process, the clicking of different items, binding data management and so on. The advantage is that it can be deeply customized, and each step is controllable. Another way is to regard the circular menu as a circular List, that is, to achieve the circular effect through the custom LayoutManager, the advantage of this way is that the custom LayoutManager only needs to implement the onLayoutChildren of the child control, Data binding is also managed by RecyclerView, which is convenient. This article is mainly through the second way to achieve, that is, custom LayoutManager way.

How to implement

The first step we need to inherit RecyclerView. LayoutManager:

class ArcLayoutManager(
    private val context: Context,
) : RecyclerView.LayoutManager() {
	override fun generateDefaultLayoutParams(a): RecyclerView.LayoutParams =
        RecyclerView.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
  
  override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        super.onLayoutChildren(recycler, state)
        fill(recycler)
    }
  
  / / the View layout
  private fun fill(recycler: RecyclerView.Recycler){}}Copy the code

After inheriting LayoutManager, onLayoutChildren is overwritten and the child View is placed via fill(), so how to implement fill() is important:

First look at the image above, the first assumes that the center coordinates (x, y) coordinate system is established with the origin of coordinates, and then the blue line in figure b as the radius, the red line a is the distance form the centre of the View to x, green line c to y for child View center distance, want to know how children View, you need to calculate the distance of the red and green. Assuming that the sub-views are placed starting from -90, assuming that there are n sub-views, then we can calculate:


Alpha. = 2 PI. / n s i n Alpha. = a / b a = s i n ( Alpha. ) b c o s Alpha. = c / b c = c o s ( Alpha. ) b x 1 = x + c y 1 = y a α = 2π / n \\ because sinα = a/b \\ therefore a = sin(α) * b \\ \\ \because cosα = c/b \\ therefore c = cos(α) * b \\ \therefore x1 = x + c \\ \therefore y1 = y – a

In the calculation, you need to use radians. You need to first convert the Angle toRadians: math.toradians (Angle). Radian calculation formula: Radian = Angle * π / 180

According to the above formula, the fill() function can be obtained as:

// mCurrAngle: current initial placement Angle
// mInitialAngle: initial Angle
private fun fill(recycler: RecyclerView.Recycler) {
  if (itemCount == 0) {
    removeAndRecycleAllViews(recycler)
    return
  }

  detachAndScrapAttachedViews(recycler)

  angleDelay = Math.PI * 2 / (mVisibleItemCount)

  if (mCurrAngle == 0.0) {
    mCurrAngle = mInitialAngle
  }

  var angle: Double = mCurrAngle
  val count = itemCount
  for (i in 0 until count) {
    val child = recycler.getViewForPosition(i)
    measureChildWithMargins(child, 0.0)
    addView(child)

    // Measure the width and height of the subview
    val cWidth: Int = getDecoratedMeasuredWidth(child)
    val cHeight: Int = getDecoratedMeasuredHeight(child)

    val cl = (innerX + radius * sin(angle)).toInt()
    val ct = (innerY - radius * cos(angle)).toInt()

    // Set the position of the child view
    var left = cl - cWidth / 2
    val top = ct - cHeight / 2
    var right = cl + cWidth / 2
    val bottom = ct + cHeight / 2

    layoutDecoratedWithMargins(
      child,
      left,
      top,
      right,
      bottom
    )
    angle += angleDelay * orientation.value
  }

  recycler.scrapList.toList().forEach {
    recycler.recycleView(it.itemView)
  }
}
Copy the code

By implementing the above fill() function, we can first realize a circular RecyclerView:

At this point, if you try to slide, it will have no effect, so you also need to achieve View placement in the sliding process, because only sliding in the vertical direction is allowed, so:

// Allow vertical sliding
override fun canScrollVertically(a) = true

// Handle the sliding process
override fun scrollVerticallyBy(
  dy: Int,
  recycler: RecyclerView.Recycler,
  state: RecyclerView.State
): Int {
  // Calculate the sliding Angle according to the sliding distance dy
  val theta = ((-dy * 180) * orientation.value / (Math.PI * radius * DEFAULT_RATIO)) * DEFAULT_SCROLL_DAMP
  // Adjust the starting Angle according to the sliding Angle
  mCurrAngle = (mCurrAngle + theta) % (Math.PI * 2)
  offsetChildrenVertical(-dy)
  fill(recycler)
  return dy
}
Copy the code

When calculating the Angle according to the sliding distance, the sliding distance in the vertical direction is approximately regarded as the arc length on the circle, and then the Angle to be slid is calculated according to the user-defined coefficient. Then rearrange the subviews.

Once you have implemented the above functions, you can scroll normally. So what happens when we want the position of the nearest subview to be automatically corrected to its original position (-90 degrees in this case) after scrolling is complete?

// This function is called when all child views have been evaluated and placed
override fun onLayoutCompleted(state: RecyclerView.State) {
    super.onLayoutCompleted(state)
    stabilize()
}

// Fix the position of the child View
private fun stabilize(a){}Copy the code

So to fix the views, we’re going to evaluate the views and reframe them when all the views are laid out, so it’s key to stabilize, so here’s the evaluation of the stabilize:

// Fix the position of the child View
private fun stabilize(a) {
  if (childCount < mVisibleItemCount / 2 || isSmoothScrolling) return

  var minDistance = Int.MAX_VALUE
  var nearestChildIndex = 0
  for (i in 0 until childCount) {
    valchild = getChildAt(i) ? :continue
    if (orientation == FillItemOrientation.LEFT_START && getDecoratedRight(child) > innerX)
    continue
    if (orientation == FillItemOrientation.RIGHT_START && getDecoratedLeft(child) < innerX)
    continue

    val y = (getDecoratedTop(child) + getDecoratedBottom(child)) / 2
    if (abs(y - innerY) < abs(minDistance)) {
      nearestChildIndex = i
      minDistance = y - innerY
    }
  }
  if (minDistance in 0.10.) returngetChildAt(nearestChildIndex)? .let { startSmoothScroll( getPosition(it),true)}}/ / rolling
private fun startSmoothScroll(
        targetPosition: Int,
        shouldCenter: Boolean
    ){}Copy the code

So one thing you do in the stabilize() function is you take the child View that’s closest to the center of the circle, and you call startSmoothScroll() and you scroll to that child View.

Here’s an implementation of startSmoothScroll() :

private val scroller by lazy {
  object : LinearSmoothScroller(context) {

    override fun calculateDtToFit(
      viewStart: Int,
      viewEnd: Int,
      boxStart: Int,
      boxEnd: Int,
      snapPreference: Int
    ): Int {
      if (shouldCenter) {
        val viewY = (viewStart + viewEnd) / 2
        var modulus = 1
        val distance: Int
        if (viewY > innerY) {
          modulus = -1
          distance = viewY - innerY
        } else {
          distance = innerY - viewY
        }
        val alpha = asin(distance.toDouble() / radius)
        return (PI * radius * DEFAULT_RATIO * alpha / (180 * DEFAULT_SCROLL_DAMP) * modulus).roundToInt()
      } else {
        return super.calculateDtToFit(
          viewStart,
          viewEnd,
          boxStart,
          boxEnd,
          snapPreference
        )
      }
    }

    override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics) =
    SPEECH_MILLIS_INCH / displayMetrics.densityDpi
  }
}

/ / rolling
private fun startSmoothScroll(
  targetPosition: Int,
  shouldCenter: Boolean
) {
  this.shouldCenter = shouldCenter
  scroller.targetPosition = targetPosition
  startSmoothScroll(scroller)
}
Copy the code

The scrolling is done using a custom LinearSmoothScroller, which consists of two overriding functions: calculateDtToFit, calculateSpeedPerPixel. CalculateDtToFit needs to be explained that when vertical scrolling, its parameters are: Top of subview, bottom of subview, top of RecyclerView, bottom of RecyclerView), and return the vertical scrolling distance. When scrolling in the horizontal direction, its parameters are respectively :(left of child View, right of child View, left of RecyclerView, right of RecyclerView), and the return value is the scrolling distance in the horizontal direction. While the calculateSpeedPerPixel function controls the slide rate, the return value indicates how long it takes to slide a pixel (ms), where SPEECH_MILLIS_INCH is your custom damping coefficient.

The calculation process of calculateDtToFit is as follows:


a = ( v i e w S t a r t + v i e w E n d ) / 2 y s i n Alpha. = a / b Alpha. = a r c s i n ( a / b ) A = (viewStart + viewEnd) / 2 -y \\ because sinα = a/b \\ therefore α = arcsin(a/b) \\

So once you figure out the Angle between the target subview and the X-axis, and then you compute the Angle of the slide in terms of the slide distance dy and then you derive the value of dy.

Through the above series of operations, you can achieve most of the effect, plus a View in the initial position of the effect:

private fun fill(recycler: RecyclerView.Recycler){... layoutDecoratedWithMargins( child, left, top, right, bottom ) scaleChild(child) ... }private fun scaleChild(child: View) {
  val y = (child.top + child.bottom) / 2
  val scale = if (abs( y - innerY) > child.measuredHeight / 2) {
    child.translationX = 0f
    1f
  } else {
    child.translationX = -child.measuredWidth * 0.2 f
    1.2 f
  }
  child.pivotX = 0f
  child.pivotY = child.height / 2f
  child.scaleX = scale
  child.scaleY = scale
}
Copy the code

When the child View is within a certain range of its initial position, enlarge it by 1.2 times. Note that the x coordinate also needs to change when the child View is enlarged.

After the above steps, the circular menu based on custom LayoutManager is realized.