There was a strange requirement to add an Indicator to a vertical linear scrolling layout. Locate several heading items in the layout. In order not to affect the original layout structure, we made this ScrollView that can anchor position, just like MarkDown anchor position. So we define a custom ScrollView to implement the business AnchorPointScrollView

Finish the renderings

              

Demand analysis

How do I scroll?

ScrollView with an anchor location. In ScrollView itself there are smoothScrollBy(Int,Int), scrollTo(Int,Int) which scrollTo the specified coordinate position. We can position the View based on this method.

SmoothScrollBy (Int,Int) is incremental scrolling. That is, increase and decrease the scrolling distance from the current position.

ScrollTo (Int,Int) is absolute coordinate scrolling. Scroll to the specified coordinate position.

Here I’ve chosen to use smoothScrollBy.

Scroll to where?

I’ve decided to use smoothScrollBy for layout scrolling. So the next step is to know how far to scroll to the next View, how to determine the coordinate position of the next View.

The first step is to locate the View. This is definitely not true if we get it through view.gety (). Because view.gety () is a nested coordinate relationship between the current View and its parent View. So you can’t use view.gety () to get the coordinates of the View.

Use getLocationOnScreen(IntArray) to get the View’s absolute position on the screen, subtract the ScrollView’s absolute position, and get it. The relative position of the current View to the ScrollView. The difference between them is how far we’re going to roll.

Code implementation

Let’s write a method that scrolls the ScrollView to the specified View position.

    @JvmOverloads
    fun scrollToView(viewId: Int, offset: Int = 0) {
        valmoveToView = findViewById<View>(viewId) moveToView ? :return
        // Get your absolute xy coordinates
        val parentLocation = IntArray(2)
        getLocationOnScreen(parentLocation)
        // Get the View's absolute coordinates
        val viewLocation = IntArray(2)
        moveToView.getLocationOnScreen(viewLocation)
        // Subtract the coordinates to get the distance to roll
        val moveViewY = viewLocation[1] - parentLocation[1]
        // Add the offset coordinate to get the final roll distance
        val needScrollY = (moveViewY - offset)
        // If the value is 0, there is no need to scroll, indicating that the coordinates are already overlapped
        if (moveViewY == 0) return
        smoothScrollBy(0, needScrollY)
    }
Copy the code

The offset argument here is the extra offset for scrolling. To make sure you have some extra space when you roll.

    // Scroll to the first View
    fun scrollView1(view: View) {
        viewBinding.scrollView.scrollToView(R.id.demo_view1)
    }
    // Scroll to the second View offset by 50 pixels
    fun scrollView2Offset(view: View) {
        viewBinding.scrollView.scrollToView(R.id.demo_view2,50)}Copy the code

You are now ready to scroll to the specified View position. Now comes the hard part.

Anchor point change position processing

You can now just scroll to the specified View, but that’s not entirely satisfying your business needs. There should be an Indicator on the UI to indicate where the scroll has reached.

So let’s first add a collection to hold the scrolling anchor View.

val registerViews = mutableListOf<View>()
Copy the code

And add methods to add Views

    fun addScrollView(vararg viewIds: Int) {
        val views = Array(viewIds.size) { index ->
            val view = findViewById<View>(viewIds[index])
            if (view == null) {
                val missingId = rootView.resources.getResourceName(viewIds[index])
                throw NoSuchElementException("The ViewId associated View was not found$missingId")
            }
            view
        }
        registerViews.clear()
        registerViews.addAll(views)
    }
Copy the code

When a ScrollView is rolling, we can use OnScrollChangeListener to listen for the rolling and get the position change information of the registered anchor View. Calculate the scroll offset and which View to scroll to in onScrollChange.

We also want to keep external listeners in use when registering OnScrollChangeListener.

    init {
        // Call a parent class that does not call itself overridden
        super.setOnScrollChangeListener(this)}// Overwrite and preserve external objects
    override fun setOnScrollChangeListener(userListener: OnScrollChangeListener?). {
        mUserListener = userListener
    }
       
    override fun onScrollChange(
        v: NestedScrollView? , scrollX:Int,
        scrollY: Int,
        oldScrollX: Int,
        oldScrollY: Int
    ) {
        // User callbackmUserListener? .onScrollChange(v, scrollX, scrollY, oldScrollX, oldScrollY)// Compute logic
        computeView()
    }
Copy the code

Everything we’re going to do next is going to be incomputeView()I’m going to do it in this method

Let’s start by encapsulating a data body that holds the View’s mapping to the coordinates.

    data class ViewPos(valview: View? .var X: Int.var Y: Int)
Copy the code

On onSizeChanged, get the current ScrollView coordinate position

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        // Update your coordinate position when the size changes
        mPos = updateViewPos(this)}private fun updateViewPos(view: View): ViewPos {
        // Get your absolute xy coordinates
        val location = IntArray(2)
        view.getLocationOnScreen(location)
        return ViewPos(view, location[0], location[1])}Copy the code

Here [mPos] will later represent the current ScrollView coordinate position

Find the last two views

How do we determine which View scroll position is already near mPos. We can use a simple query algorithm to find out.

demo

We can go through the View’s Y coordinate and compare it to the current Y coordinate and get the two adjacent values of the current Y coordinate. Let’s demonstrate this with a test method

     @Test
    funThe closest value(a) {
        val list = arrayListOf<Int> (-1, -2, -3.14.5.62.7.80.9.100.200.500.1123)
        // Find the two values closest to tag
        val tag: Long = 5
        / / the tag value on the left
        var leftVal: Int = Int.MIN_VALUE
        / / the tag value on the right
        var rightVal: Int = Int.MAX_VALUE
        // Sort first
        list.sort()

        for (value in list) {
            // The current value is less than Tag
            if (tag >= value) {
                if (tag - value == min(tag - value, tag - leftVal)) {
                    leftVal = value
                }
            } else {
                // The current value is greater than Tag
                if (value - tag == min(value - tag, rightVal - tag)) {
                    rightVal = value
                }
            }
        }

        println(" left=$leftVal tag=$tag  right=$rightVal")}Copy the code

You can also run the example and change the size of the tag to verify this.

We apply this simple algorithm abstractly to our business logic.

private fun computeView(a){ mPos ? :return
         if (registerViews.isEmpty()) return
        // Check whether the scroll is to the bottom, which will be used later
        val isScrollBottom = scrollY == getMaxScrollY()
        // Retrieve two adjacent views
        // The previous View is cached
        var previousView = ViewPos(null.0.Int.MIN_VALUE)
        // Next View cache
        var nextView = ViewPos(null.0.Int.MAX_VALUE)
        // View subscript of the current scroll
        var scrollIndex = -1
        // Find the two views and coordinate positions adjacent to the trigger position by traversing the registered views
        / / [this lookup algorithm to check the [com. Example. The scrollview. ExampleUnitTest]
        registerViews.forEachIndexed { index, it ->
            val viewPos = updateViewPos(it)
            if(mPos!! .Y >= viewPos.Y) {if(mPos!! .Y.toLong() - viewPos.Y == min( mPos!! .Y.toLong() - viewPos.Y, mPos!! .Y.toLong() - previousView.Y ) ) { scrollIndex = index previousView = viewPos } }else {
                if(viewPos.Y - mPos!! .Y.toLong() == min( viewPos.Y - mPos!! .Y.toLong(), nextView.Y - mPos!! .Y.toLong() ) ) { nextView = viewPos } } } }Copy the code

From the above calculation, we get the previous and next ViewPos of the current coordinate mPos, and also get the index position to scroll to. Null if there is no registered View before the current scroll position. Null if there is no registered View after the current scroll position.

Now we have these information parameters:

  1. mPos:The top coordinate of the current scrolllayout ScrollView.
  2. previousView:The View preceding the current scroll position, or the y-coordinate is less thanmPosThe nearest View of.
  3. nextView:The next View of the current scroll position, or the y-coordinate is greater thanmPosThe nearest View of.
  4. scrollIndex:That is, which registered View scope you are currently scrolling into. The period of change of this parameter is the next onenextViewBecome apreviousViewUntil now, this value will remain currentpreviousViewThe subscript position of.

Calculation of distance

Calculate the distance between previousView and mPos, and the distance between nextView and mPos. This distance is actually pretty easy to calculate. You can just take two coordinates and subtract them.

private fun computeView(a) {
    // Ignore the previousView and nextView calculations above.//========================= before and after the View scroll difference
        // Distance to the distance that the previous View needs to scroll/distance from the previous View
        var previousViewDistance = 0
        // The distance to the next View/the distance to the next View
        var nextViewDistance = 0

        if(previousView.view ! =null) { previousViewDistance = mPos!! .Y - previousView.Y }else {
            // No previous View, this is the first View
            if (scrollIndex == -1) {
                scrollIndex = 0}}if(nextView.view ! =null) { nextViewDistance = nextView.Y - mPos!! .Y }else {
            // There is no last View, this is the last View
            if (scrollIndex == -1) {
                scrollIndex = registerViews.size - 1}}// Modify the scroll subscript to force the last anchor point View when scrolling to the bottom
        if (isScrollBottom && isFixBottom) {
            scrollIndex = registerViews.size - 1}}Copy the code

In this code, when calculating the scroll distance, we need to evaluate View==NULL first. Because if it’s NULL, there are two cases.

  1. I started scrolling before I got to the first View that I registered. The first View is zeronextView.previousView= = null.
  2. Scroll to the bottom, scroll down, there are no registered anchor points behind, the last View ispreviousView.nextView==null

The coordinate position of scrollIndex was also repaired while calculating the distance. If you haven’t scrolled to the first registered anchor View, then scrollIndex=0, and if there’s no nextView, you’re at the end, and scrollIndex= last. There is also a case where the height of the last registered anchor View is not enough to scroll to the top of the ScrollView. I’m going to fix this subscript position. We initialize the isScrollBottom parameter at the beginning of the search for two adjacent views. And isFixBottom we set it up according to the business needs.

Finally, two parameters are obtained by calculating the distance:

~ previousViewDistance: previousView distance with mPos.

~ nextViewDistance: The distance between nextView and mPos.

Calculate percentage

So given the distance, then we can figure out when we roll uppreviousViewEscape percentage vsnextViewPercentage of entry.

PreviousRatio = previousViewDistance/ Distance between the previousView and the next View

NextRatio =1.0-prevousRatio for the next View.

code



    private fun computeView(a) {
    // Ignore the previousView and nextView calculations above.//========================= before and after the View scroll difference.//=============== before and after View escape entry percentage
        // Distance from the previous View percentage value
        var previousRatio = 0.0 f
        // Distance to the next View percentage value
        var nextRatio = 0.0 f
        // The distance between two views
        var viewDistanceDifference = 0
        // The coordinate of the root View
        val rootPos = getRootViewPos()
        // Calculate the Y difference between the two nearest views [viewDistanceDifference]
        if(previousView.view ! =null&& nextView.view ! =null) {
            viewDistanceDifference = nextView.Y - previousView.Y
        } else if(rootPos ! =null) {
            if (previousView.view == null&& nextView.view ! =null) {
                // No previous View
                // Then the distance to the first View = the next View - coordinates with the top of the layout
                viewDistanceDifference = nextView.Y - rootPos.Y
            } else if (nextView.view == null&& previousView.view ! =null) {
                // There is no next View
                // The previous View is the last registered anchor View.
                // Distance = bottom Y coordinate - previous ViewY coordinate
                val bottomY = rootPos.Y + getMaxScrollY() // Maximum scrolling distance
                viewDistanceDifference = bottomY - previousView.Y
            }
        }

//===================== calculates the percentage value
        if(nextViewDistance ! =0) {
            // Distance from next View/total distance = escape percentage of previous View
            previousRatio = nextViewDistance.toFloat() / viewDistanceDifference
            // The other way around is the entry percentage of the next View
            nextRatio = 1f - previousRatio
            if (previousViewDistance == 0) {
                // There will be no escape percentage of the first View if the first anchor point is not reached;
                // previousRatio is the escape percentage of the top coordinate
                previousRatio = 0f}}else if(previousViewDistance ! =0) {
            / / in the same way. Distance of previous View/total distance = escape percentage of next View
            nextRatio = previousViewDistance.toFloat() / viewDistanceDifference
            // The reverse is the entry percentage of the previous View
            previousRatio = 1f - nextRatio
            if (nextViewDistance == 0) {
                // If the anchor point calculates the entry percentage of the last View, the next View will not exist
                // nextRatio is the percentage of the bottom coordinates entering and reaching unscrollable
                nextRatio = 0f}}}/** * get the maximum sliding distance */
    fun getMaxScrollY(a): Int {
        if(mMaxScrollY ! = -1) {
            return mMaxScrollY
        }
        if (childCount == 0) {
            // Nothing to do.
            return -1
        }
        val child = getChildAt(0)
        val lp = child.layoutParams as LayoutParams
        val childSize = child.height + lp.topMargin + lp.bottomMargin
        val parentSpace = height - paddingTop - paddingBottom
        mMaxScrollY = 0.coerceAtLeast(childSize - parentSpace)
        return mMaxScrollY
    }
    
    // Get the coordinates of the root View. The coordinates of the ScrollView are constant.
    // The LinerLayout coordinates of the root layout change according to scrolling
    private fun getRootViewPos(a): ViewPos? {
        if (childCount == 0) return null
        val rootView = getChildAt(0)
        val parentLocation = IntArray(2)
        rootView.getLocationOnScreen(parentLocation)
        return ViewPos(null, parentLocation[0], parentLocation[1])}Copy the code

After the above calculation, we got the following data:

  1. viewDistanceDifference:previousViewwithnextViewThe difference in Y coordinates. The distance between the front and the back
  2. previousRatio: The escape percentage of the previous View,previousViewwithmPosPercentage of distance.
  3. nextRatio: The entry percentage of the next View,nextViewwithmPosThe percentage of distance between.

Then you’re done.

The callback listener

Finally, we sorted these parameters and handed them over to the page to handle.

Add an interface

 interface OnViewPointChangeListener {

        fun onScrollPointChange(previousDistance: Int, nextDistance: Int, index: Int)

        fun onScrollPointChangeRatio(
            previousFleeRatio: Float,
            nextEnterRatio: Float,
            index: Int,
            scrollPixel: Int,
            isScrollBottom: Boolean
        )

        fun onPointChange(index: Int, isScrollBottom: Boolean)
    }
Copy the code

Fill in the data

    private fun computeView(a) {
    // Ignore the previous calculation code.//============== data callback

        // Trigger the anchor change callback
        if(mViewPoint ! = scrollIndex) { mViewPoint = scrollIndex onViewPointChangeListener? .onPointChange(mViewPoint, isScrollBottom) }// Trigger the scrolldistance change callbackonViewPointChangeListener? .onScrollPointChange( previousViewDistance, nextViewDistance, scrollIndex )// Triggers the escape into the percentage change callback
        if (previousRatio in 0f..1f && nextRatio in 0f..1f) {
            // Only two values in the correct range can be processed otherwise an exception message is printedonViewPointChangeListener? .onScrollPointChangeRatio( previousRatio, nextRatio, scrollIndex, previousViewDistance, isScrollBottom ) }else {
            Log.e(
                TAG, "computeView:" +
                        "\n previousRatio = $previousRatio" +
                        "\n nextRatio = $nextRatio")}}Copy the code

Take one last look at the finished result

The indicator here is the MagicIndicator. The code is all on GitHub. Take a look for yourself.

There’s a lot of room for improvement. For example, the algorithm for finding the two closest views. Make index changes more elegant and so on when the last 1-3 views registered are not enough to scroll to the top. It needs to be improved.

Finally, the Nuggets Markdown editor GIF does not allow image sizing. Causes GIF preview to be large. Not all at once.