Ashfaq Salehin
Ashfaq Salehin

Reputation: 53

Scroll by only using the bar not the child view itself

In my app I have a custom view like below image,

Audio Visualizer

This is an audio visualiser, which grow across X axis upon zoom in. So I need it inside a scrollview to scroll through the audio graph. The layout is currently done like below,

<HorizontalScrollView
        android:id="@+id/fileWaveViewScroll"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:fillViewport="true"
        style="@style/CustomScrollbar">

        <com.bluehub.fastmixer.common.views.FileWaveView
            android:id="@+id/fileWaveView"
            android:layout_width="wrap_content"
            android:layout_height="120dp"
            app:audioFileUiState="@{audioFileUiState}"
            app:samplesReader="@{eventListener.readSamplesCallbackWithIndex(audioFileUiState.path)}"
            app:fileWaveViewStore="@{fileWaveViewStore}"
            tools:layout_height="120dp"/>
</HorizontalScrollView>

Custom view FileWaveView is extended from a LinearLayout.

Now I want a the scrolling behaviour such that when I am scrolling inside the custom view (audio visualiser graph), the scrollview should not scroll. But when I am scrolling by touching the bar itself, then the scrollbar will scroll it's content.

I want this behaviour because I want the scroll gesture to be used for some other action inside the visualiser graph (to adjust a segment selector width, which I will integrate later).

Please suggest me how can I make this scrollbar scrolling pattern, while the view will be scrolled through the bar only. Should I refrain from using HorizontalScrollBar and make own scrolling behaviour inside of the custom view itself?

Upvotes: 3

Views: 471

Answers (2)

Ashfaq Salehin
Ashfaq Salehin

Reputation: 53

From kind help of SlothCoding, I was able to fix the issue. I created a custom scrollbar and used it to control the HorizontalScrollView. The relevant code is given below,

Custom Scroll View

import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.HorizontalScrollView

class LockedHorizontalScrollView(
    context: Context,
    attrs: AttributeSet? = null) : HorizontalScrollView(context, attrs) {

    init {
        isVerticalScrollBarEnabled = false
        isHorizontalScrollBarEnabled = false
    }

    override fun onTouchEvent(ev: MotionEvent?): Boolean {
        return false
    }

    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        return false
    }
}

ScrollBar

package com.bluehub.fastmixer.common.views

import android.content.Context
import android.util.AttributeSet
import android.view.*
import android.widget.HorizontalScrollView
import androidx.constraintlayout.widget.ConstraintLayout
import com.bluehub.fastmixer.databinding.CustomScrollBarBinding

class CustomHorizontalScrollBar(context: Context, attributeSet: AttributeSet?)
    : ConstraintLayout(context, attributeSet) {

    private val binding: CustomScrollBarBinding
    private lateinit var mHorizontalScrollView: HorizontalScrollView
    private lateinit var mView: View

    private val mScrollBar: CustomHorizontalScrollBar
    private val mScrollTrack: View
    private val mScrollThumb: View

    private val gestureListener = object: GestureDetector.SimpleOnGestureListener() {
        override fun onScroll(
            e1: MotionEvent?,
            e2: MotionEvent?,
            distanceX: Float,
            distanceY: Float
        ): Boolean {

            handleScrollOfThumb(e2, distanceX)
            return true
        }

        override fun onDown(e: MotionEvent?): Boolean {
            return true
        }
    }

    private val gestureDetector: GestureDetector

    init {
        val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
        binding = CustomScrollBarBinding.inflate(inflater, this, true)

        mScrollBar = this

        mScrollTrack = binding.scrollTrack
        mScrollThumb = binding.scrollThumb

        gestureDetector = GestureDetector(context, gestureListener)
    }

    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        return gestureDetector.onTouchEvent(ev)
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        return gestureDetector.onTouchEvent(event)
    }

    fun setHorizontalScrollView(horizontalScrollView: HorizontalScrollView) {
        this.mHorizontalScrollView = horizontalScrollView
    }

    fun setControlledView(view: View) {
        this.mView = view
        setViewWidthListener()
    }

    private fun handleScrollOfThumb(event: MotionEvent?, distanceX: Float) {
        event?: return

        if (event.x >= mScrollThumb.left && event.x <= mScrollThumb.left + mScrollThumb.width) {

            var newLeft = mScrollThumb.left - distanceX.toInt()
            var newRight = mScrollThumb.right - distanceX.toInt()

            if (newRight <= mScrollBar.width && newLeft >= 0) {
                repositionScrollThumb(newLeft, newRight)
            }
            // Thumb is not at left nor right, but distanced asked to traverse by move is more
            else if (mScrollThumb.left != 0 && mScrollThumb.right < mScrollBar.width - 1) {

                // Distance from right end of bar
                val distanceToRight = mScrollBar.width - mScrollThumb.right - 1

                // Get the closest distance to move the thumb to
                val newDist = if (mScrollThumb.left < distanceToRight) {
                    mScrollThumb.left
                } else {
                    -distanceToRight
                }

                newLeft = mScrollThumb.left - newDist
                newRight = mScrollThumb.right - newDist

                repositionScrollThumb(newLeft, newRight)
            }
        }
    }

    private fun repositionScrollThumb(newLeft: Int, newRight: Int) {
        mScrollThumb.layout(newLeft, mScrollThumb.top, newRight, mScrollThumb.bottom)
        performScrollOnScrollView()
    }

    private fun performScrollOnScrollView() {
        val ratio = mView.width.toFloat() / mScrollBar.width.toFloat()
        val posToScroll = (ratio * mScrollThumb.left).toInt()
        mHorizontalScrollView.post {
            mHorizontalScrollView.scrollTo(posToScroll, mHorizontalScrollView.top)
        }
    }

    private fun setViewWidthListener() {
        mView.addOnLayoutChangeListener { view: View, _, _, _, _, _, _, _, _ ->

            if (mScrollTrack.width == 0) return@addOnLayoutChangeListener

            val w = view.width

            val ratio = w.toFloat() / mScrollTrack.width.toFloat()

            if (ratio == 1.0f) {
                mScrollBar.visibility = View.INVISIBLE
            } else {
                mScrollBar.visibility = View.VISIBLE

                val layoutParams = mScrollThumb.layoutParams
                val newWidth = (mScrollTrack.width.toFloat() / ratio).toInt()
                if (mScrollThumb.width != newWidth) {
                    mScrollThumb.post {
                        mScrollThumb.layoutParams = layoutParams
                    }
                }
            }
        }
    }
}

Scrollbar Layout,

<?xml version="1.0" encoding="utf-8"?>

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/guideline"
            android:layout_width="1dp"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            app:layout_constraintGuide_percent="0.5"/>

        <View
            android:id="@+id/scrollTrack"
            android:layout_width="match_parent"
            android:layout_height="@dimen/scrollbar_track_height"
            android:background="@drawable/scrollbar_horizontal_track"
            app:layout_constraintTop_toTopOf="@id/guideline"
            app:layout_constraintBottom_toBottomOf="@+id/guideline"
            app:layout_constraintStart_toStartOf="parent" />


        <View
            android:id="@+id/scrollThumb"
            android:layout_width="18dp"
            android:layout_height="@dimen/scrollbar_thumb_height"
            android:background="@drawable/scrollbar_horizontal_thumb"
            app:layout_constraintTop_toTopOf="@id/guideline"
            app:layout_constraintBottom_toBottomOf="@+id/guideline"
            app:layout_constraintStart_toStartOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>

Layout of the custom view inside of the scrollbar,

<com.bluehub.fastmixer.common.views.LockedHorizontalScrollView
            android:id="@+id/fileWaveViewScroll"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:fillViewport="true"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent">

            <com.bluehub.fastmixer.common.views.FileWaveView
                android:id="@+id/fileWaveView"
                android:layout_width="wrap_content"
                android:layout_height="@dimen/audio_file_view_height"
                app:audioFileUiState="@{audioFileUiState}"
                app:samplesReader="@{eventListener.readSamplesCallbackWithIndex(audioFileUiState.path)}"
                app:fileWaveViewStore="@{fileWaveViewStore}"
                tools:layout_height="120dp">

                <com.bluehub.fastmixer.common.views.AudioWidgetSlider
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content" />

            </com.bluehub.fastmixer.common.views.FileWaveView>

        </com.bluehub.fastmixer.common.views.LockableHorizontalScrollView>

        <com.bluehub.fastmixer.common.views.CustomHorizontalScrollBar
            android:id="@+id/fileWaveViewScrollBar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/fileWaveViewScroll" />

Finally in the parent view,

binding.apply {
    fileWaveViewScrollBar.setHorizontalScrollView(fileWaveViewScroll)
    fileWaveViewScrollBar.setControlledView(fileWaveView)
}

We have some drawables for scroll track and scroll thumb.

Scroll track,

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" >

    <gradient
        android:angle="0"
        android:endColor="#9BA3C5"
        android:startColor="#8388A4" />

    <corners android:radius="8dp" />

</shape>

Scroll thumb,

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" >
    <gradient
        android:angle="0"
        android:endColor="#005A87"
        android:startColor="#007AB8" />

    <corners android:radius="8dp" />

</shape>

Upvotes: 2

SlothCoding
SlothCoding

Reputation: 1706

TL;DR

There isn't anything that would help you disable your scrolling on HorizontalScrollView with slide event and only do it when ScrollBar is used. The reason for this is that ScrollBar isn't a separate view from HorizontalScrollView and it doesn't have any listeners implemented on itself. There is an option for third-party libraries to work with audio visualization or audio files or to implement this workaround I posted below. Not quite sure if it is going to work at first because it needs a lot of work but maybe is worth trying.


I am not sure that there is something like that in HorizontalScrollView methods. But there are some options which can help you do this. I couldn't find anything, like a tutorial, to make this happen for you but I might make this work with some workarounds. First, there is this:

You cannot disable the scrolling of a ScrollView. You would need to extend to ScrollView and override the onTouchEvent method to return false when some condition is matched.

So, you need to first do this in order to prevent the user from scrolling the view. This will disable scrolling on touch and that's what you need in this case. Now, I am not sure if this will work 100% since I can't test it right now but I am doing my best to help. After you disabled your touch events on your ScrollView now you need to hide the ScrollBar, because you can't use it since it is part of the HorizontalScrollView and it doesn't have only listeners of himself so I think each touch event will be intercepted by the HorizontalScrollView itself and not separated on content and scrollbar. To hide the scrollbar in your HorizontalScrollView you can use XML attribute like this:

android:scrollbars="none"

or from your code like this:

view.setVerticalScrollBarEnabled(false);
view.setHorizontalScrollBarEnabled(false);

Now you need to create your own scrollbar or just buttons that will do the scrolling inside the view. These buttons will change the scrolling position onClick event. But first, let's disable the scrolling on HorizontalScrollView.

class LockableScrollView extends HorizontalScrollView {

    ...

    // true if we can scroll (not locked)
    // false if we cannot scroll (locked)
    private boolean mScrollable = true;

    public void setScrollingEnabled(boolean enabled) {
        mScrollable = enabled;
    }

    public boolean isScrollable() {
        return mScrollable;
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        // Do not allow touch events.
        return false;
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        // Do not allow touch events.
        return false;
    }

}

Now instead of classic HorizontalScrollView you can use this:

<com.mypackagename.LockableScrollView 
    android:id="@+id/QuranGalleryScrollView" 
    android:layout_height="fill_parent" 
    android:layout_width="fill_parent">

    //your view

</com.mypackagename.LockableScrollView>

This is from the answer to this question: https://stackoverflow.com/a/5763815/14759470

Now, let's go back to those buttons. Let's say you have two buttons on each side, one for scroll to right and one for scroll to left. You can handle scrolling like this:

rightBtn.setOnClickListener(new View.OnClickListener() {

    @Override
    public void onClick(View v) {
        hsv.scrollTo((int)hsv.getScrollX() + 10, (int)hsv.getScrollY());
    }
});

or if you want it be smooth you can use onTouchListener like this:

rightBtn.setOnTouchListener(new View.OnTouchListener() {

    private Handler mHandler;
    private long mInitialDelay = 300;
    private long mRepeatDelay = 100;

    @Override
    public boolean onTouch(View v, MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (mHandler != null)
                    return true;
                mHandler = new Handler();
                mHandler.postDelayed(mAction, mInitialDelay);
                break;
            case MotionEvent.ACTION_UP:
                if (mHandler == null)
                    return true;
                mHandler.removeCallbacks(mAction);
                mHandler = null;
                break;
        }
        return false;
    }

    Runnable mAction = new Runnable() {
        @Override
        public void run() {
            hsv.scrollTo((int) hsv.getScrollX() + 10, (int) hsv.getScrollY());
            mHandler.postDelayed(mAction, mRepeatDelay);
        }
    };
});

where hsv is your HorizontalScrollView. Now you can follow the logic for the left button. To handle show/hide buttons when the view is at the start/end you can follow this question and answers: https://stackoverflow.com/a/7672711/14759470

If this isn't something you would like to do then I am not sure of other options you have since my knowledge can only go this far. But, maybe consider looking at some third-party libraries on GitHub or somewhere else which are working with audio files. Like this one: https://github.com/ferPrieto/SoundLine

If you run into any problems while implementing this code please let me know in the comments. Since I didn't have time to test this before posting as an answer, but I'll be glad to help you with any future issues you come across.

Upvotes: 1

Related Questions