From 4ad2722e81f31abee4417dc44660927e241e973b Mon Sep 17 00:00:00 2001 From: antonio Date: Tue, 1 Aug 2023 10:34:39 +0200 Subject: [PATCH] feat: added fast scrollbar to folder navigation screen. --- .../helper/recyclerview/FastScrollbar.java | 197 ++++++++++++++++++ .../tempo/ui/adapter/MusicIndexAdapter.java | 14 +- .../tempo/ui/fragment/IndexFragment.java | 3 + .../res/drawable/fast_scrollbar_bubble.xml | 15 ++ .../res/drawable/fast_scrollbar_handle.xml | 17 ++ app/src/main/res/layout/fragment_index.xml | 27 ++- .../main/res/layout/layout_fast_scrollbar.xml | 26 +++ 7 files changed, 290 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/cappielloantonio/tempo/helper/recyclerview/FastScrollbar.java create mode 100644 app/src/main/res/drawable/fast_scrollbar_bubble.xml create mode 100644 app/src/main/res/drawable/fast_scrollbar_handle.xml create mode 100644 app/src/main/res/layout/layout_fast_scrollbar.xml diff --git a/app/src/main/java/com/cappielloantonio/tempo/helper/recyclerview/FastScrollbar.java b/app/src/main/java/com/cappielloantonio/tempo/helper/recyclerview/FastScrollbar.java new file mode 100644 index 00000000..d479813a --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/helper/recyclerview/FastScrollbar.java @@ -0,0 +1,197 @@ +package com.cappielloantonio.tempo.helper.recyclerview; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.IdRes; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.core.view.ViewCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +public class FastScrollbar extends LinearLayout { + private static final int BUBBLE_ANIMATION_DURATION = 100; + private static final int TRACK_SNAP_RANGE = 5; + + private TextView bubble; + private View handle; + private RecyclerView recyclerView; + private int height; + private boolean isInitialized = false; + private ObjectAnimator currentAnimator = null; + + private final RecyclerView.OnScrollListener onScrollListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { + updateBubbleAndHandlePosition(); + } + }; + + public interface BubbleTextGetter { + String getTextToShowInBubble(int pos); + } + + public FastScrollbar(final Context context, final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context); + } + + public FastScrollbar(final Context context) { + super(context); + init(context); + } + + public FastScrollbar(final Context context, final AttributeSet attrs) { + super(context, attrs); + init(context); + } + + protected void init(Context context) { + if (isInitialized) return; + isInitialized = true; + setOrientation(HORIZONTAL); + setClipChildren(false); + } + + public void setViewsToUse(@LayoutRes int layoutResId, @IdRes int bubbleResId, @IdRes int handleResId) { + final LayoutInflater inflater = LayoutInflater.from(getContext()); + inflater.inflate(layoutResId, this, true); + bubble = findViewById(bubbleResId); + if (bubble != null) bubble.setVisibility(INVISIBLE); + handle = findViewById(handleResId); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + height = h; + updateBubbleAndHandlePosition(); + } + + @Override + public boolean onTouchEvent(@NonNull MotionEvent event) { + final int action = event.getAction(); + switch (action) { + case MotionEvent.ACTION_DOWN: + if (event.getX() < handle.getX() - ViewCompat.getPaddingStart(handle)) return false; + if (currentAnimator != null) currentAnimator.cancel(); + if (bubble != null && bubble.getVisibility() == INVISIBLE) showBubble(); + handle.setSelected(true); + case MotionEvent.ACTION_MOVE: + final float y = event.getY(); + setBubbleAndHandlePosition(y); + setRecyclerViewPosition(y); + return true; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + handle.setSelected(false); + hideBubble(); + return true; + } + return super.onTouchEvent(event); + } + + public void setRecyclerView(final RecyclerView recyclerView) { + if (this.recyclerView != recyclerView) { + if (this.recyclerView != null) + this.recyclerView.removeOnScrollListener(onScrollListener); + this.recyclerView = recyclerView; + if (this.recyclerView == null) return; + recyclerView.addOnScrollListener(onScrollListener); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (recyclerView != null) { + recyclerView.removeOnScrollListener(onScrollListener); + recyclerView = null; + } + } + + private void setRecyclerViewPosition(float y) { + if (recyclerView != null) { + final int itemCount = recyclerView.getAdapter().getItemCount(); + float proportion; + if (handle.getY() == 0) proportion = 0f; + else if (handle.getY() + handle.getHeight() >= height - TRACK_SNAP_RANGE) + proportion = 1f; + else proportion = y / (float) height; + final int targetPos = getValueInRange(0, itemCount - 1, (int) (proportion * (float) itemCount)); + ((LinearLayoutManager) recyclerView.getLayoutManager()).scrollToPositionWithOffset(targetPos, 0); + final String bubbleText = ((BubbleTextGetter) recyclerView.getAdapter()).getTextToShowInBubble(targetPos); + if (bubble != null) { + bubble.setText(bubbleText); + if (TextUtils.isEmpty(bubbleText)) { + hideBubble(); + } else if (bubble.getVisibility() == View.INVISIBLE) { + showBubble(); + } + } + } + } + + private int getValueInRange(int min, int max, int value) { + int minimum = Math.max(min, value); + return Math.min(minimum, max); + } + + private void updateBubbleAndHandlePosition() { + if (bubble == null || handle.isSelected()) return; + + final int verticalScrollOffset = recyclerView.computeVerticalScrollOffset(); + final int verticalScrollRange = recyclerView.computeVerticalScrollRange(); + float proportion = (float) verticalScrollOffset / ((float) verticalScrollRange - height); + setBubbleAndHandlePosition(height * proportion); + } + + private void setBubbleAndHandlePosition(float y) { + final int handleHeight = handle.getHeight(); + handle.setY(getValueInRange(0, height - handleHeight, (int) (y - handleHeight / 2))); + if (bubble != null) { + int bubbleHeight = bubble.getHeight(); + bubble.setY(getValueInRange(0, height - bubbleHeight - handleHeight / 2, (int) (y - bubbleHeight))); + } + } + + private void showBubble() { + if (bubble == null) return; + bubble.setVisibility(VISIBLE); + if (currentAnimator != null) currentAnimator.cancel(); + currentAnimator = ObjectAnimator.ofFloat(bubble, "alpha", 0f, 1f).setDuration(BUBBLE_ANIMATION_DURATION); + currentAnimator.start(); + } + + private void hideBubble() { + if (bubble == null) return; + if (currentAnimator != null) currentAnimator.cancel(); + currentAnimator = ObjectAnimator.ofFloat(bubble, "alpha", 1f, 0f).setDuration(BUBBLE_ANIMATION_DURATION); + currentAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + bubble.setVisibility(INVISIBLE); + currentAnimator = null; + } + + @Override + public void onAnimationCancel(Animator animation) { + super.onAnimationCancel(animation); + bubble.setVisibility(INVISIBLE); + currentAnimator = null; + } + }); + currentAnimator.start(); + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/MusicIndexAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/MusicIndexAdapter.java index 9f820c39..6f148123 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/MusicIndexAdapter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/MusicIndexAdapter.java @@ -9,16 +9,17 @@ import androidx.media3.common.util.UnstableApi; import androidx.recyclerview.widget.RecyclerView; import com.cappielloantonio.tempo.databinding.ItemLibraryMusicIndexBinding; -import com.cappielloantonio.tempo.glide.CustomGlideRequest; +import com.cappielloantonio.tempo.helper.recyclerview.FastScrollbar; import com.cappielloantonio.tempo.interfaces.ClickCallback; import com.cappielloantonio.tempo.subsonic.models.Artist; import com.cappielloantonio.tempo.util.Constants; import java.util.Collections; import java.util.List; +import java.util.Objects; @UnstableApi -public class MusicIndexAdapter extends RecyclerView.Adapter { +public class MusicIndexAdapter extends RecyclerView.Adapter implements FastScrollbar.BubbleTextGetter { private final ClickCallback click; private List artists; @@ -41,10 +42,10 @@ public class MusicIndexAdapter extends RecyclerView.Adapter + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fast_scrollbar_handle.xml b/app/src/main/res/drawable/fast_scrollbar_handle.xml new file mode 100644 index 00000000..ae1ce48a --- /dev/null +++ b/app/src/main/res/drawable/fast_scrollbar_handle.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_index.xml b/app/src/main/res/layout/fragment_index.xml index 6e9f3a6e..f260bd8d 100644 --- a/app/src/main/res/layout/fragment_index.xml +++ b/app/src/main/res/layout/fragment_index.xml @@ -47,13 +47,30 @@ - + app:layout_behavior="@string/appbar_scrolling_view_behavior"> + + + + + diff --git a/app/src/main/res/layout/layout_fast_scrollbar.xml b/app/src/main/res/layout/layout_fast_scrollbar.xml new file mode 100644 index 00000000..fb7ce11b --- /dev/null +++ b/app/src/main/res/layout/layout_fast_scrollbar.xml @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file