From 9e1f6af21e30c57eb40d393573a5cf6a0dd9a307 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 17 Jan 2023 14:30:49 -0700 Subject: [PATCH] list: switch to header divider Switch to item decorations to manage header dividers. This is much more reliable than encoding it in-data. Only cost comes in that it forces me to backport yet another component since I still can't update MDC after over half a year. --- ....java => BackportBottomSheetBehavior.java} | 32 +- ...BackportMaterialDividerItemDecoration.java | 409 ++++++++++++++++++ .../java/org/oxycblt/auxio/MainFragment.kt | 46 +- .../main/java/org/oxycblt/auxio/list/Data.kt | 2 +- .../auxio/list/recycler/AuxioRecyclerView.kt | 7 + .../list/recycler/HeaderItemDecoration.kt | 44 ++ .../auxio/list/recycler/ViewHolders.kt | 4 +- .../org/oxycblt/auxio/search/SearchAdapter.kt | 11 + .../oxycblt/auxio/search/SearchFragment.kt | 1 + .../oxycblt/auxio/search/SearchViewModel.kt | 8 +- .../settings/ui/BasePreferenceFragment.kt | 1 + .../ui/PreferenceHeaderItemDecoration.kt | 47 ++ .../auxio/ui/BaseBottomSheetBehavior.kt | 4 +- .../auxio/ui/BottomSheetContentBehavior.kt | 12 +- app/src/main/res/layout/dialog_music_dirs.xml | 36 +- app/src/main/res/layout/item_bare_header.xml | 9 - app/src/main/res/layout/item_header.xml | 6 - app/src/main/res/xml/preferences_audio.xml | 3 +- app/src/main/res/xml/preferences_music.xml | 3 +- .../main/res/xml/preferences_personalize.xml | 3 +- 20 files changed, 597 insertions(+), 91 deletions(-) rename app/src/main/java/com/google/android/material/bottomsheet/{NeoBottomSheetBehavior.java => BackportBottomSheetBehavior.java} (98%) create mode 100644 app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java create mode 100644 app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt delete mode 100644 app/src/main/res/layout/item_bare_header.xml diff --git a/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java similarity index 98% rename from app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java rename to app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java index 51a5e11fd..4d5da721d 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java @@ -83,9 +83,10 @@ import java.util.Map; * window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for * BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}. * - * Modified at several points by Alexander Capehart to work around miscellaneous issues. + * Modified at several points by Alexander Capehart backport miscellaneous fixes not currently + * obtainable in the currently used MDC library. */ -public class NeoBottomSheetBehavior extends CoordinatorLayout.Behavior { +public class BackportBottomSheetBehavior extends CoordinatorLayout.Behavior { /** Listener for monitoring events about bottom sheets. */ public abstract static class BottomSheetCallback { @@ -318,9 +319,9 @@ public class NeoBottomSheetBehavior extends CoordinatorLayout.Be private int expandHalfwayActionId = View.NO_ID; - public NeoBottomSheetBehavior() {} + public BackportBottomSheetBehavior() {} - public NeoBottomSheetBehavior(@NonNull Context context, @Nullable AttributeSet attrs) { + public BackportBottomSheetBehavior(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); peekHeightGestureInsetBuffer = @@ -1980,7 +1981,7 @@ public class NeoBottomSheetBehavior extends CoordinatorLayout.Be skipCollapsed = source.readInt() == 1; } - public SavedState(Parcelable superState, @NonNull NeoBottomSheetBehavior behavior) { + public SavedState(Parcelable superState, @NonNull BackportBottomSheetBehavior behavior) { super(superState); this.state = behavior.state; this.peekHeight = behavior.peekHeight; @@ -1990,12 +1991,12 @@ public class NeoBottomSheetBehavior extends CoordinatorLayout.Be } /** - * This constructor does not respect flags: {@link NeoBottomSheetBehavior#SAVE_PEEK_HEIGHT}, {@link - * NeoBottomSheetBehavior#SAVE_FIT_TO_CONTENTS}, {@link NeoBottomSheetBehavior#SAVE_HIDEABLE}, {@link - * NeoBottomSheetBehavior#SAVE_SKIP_COLLAPSED}. It is as if {@link NeoBottomSheetBehavior#SAVE_NONE} + * This constructor does not respect flags: {@link BackportBottomSheetBehavior#SAVE_PEEK_HEIGHT}, {@link + * BackportBottomSheetBehavior#SAVE_FIT_TO_CONTENTS}, {@link BackportBottomSheetBehavior#SAVE_HIDEABLE}, {@link + * BackportBottomSheetBehavior#SAVE_SKIP_COLLAPSED}. It is as if {@link BackportBottomSheetBehavior#SAVE_NONE} * were set. * - * @deprecated Use {@link #SavedState(Parcelable, NeoBottomSheetBehavior)} instead. + * @deprecated Use {@link #SavedState(Parcelable, BackportBottomSheetBehavior)} instead. */ @Deprecated public SavedState(Parcelable superstate, @State int state) { @@ -2036,24 +2037,24 @@ public class NeoBottomSheetBehavior extends CoordinatorLayout.Be } /** - * A utility function to get the {@link NeoBottomSheetBehavior} associated with the {@code view}. + * A utility function to get the {@link BackportBottomSheetBehavior} associated with the {@code view}. * - * @param view The {@link View} with {@link NeoBottomSheetBehavior}. - * @return The {@link NeoBottomSheetBehavior} associated with the {@code view}. + * @param view The {@link View} with {@link BackportBottomSheetBehavior}. + * @return The {@link BackportBottomSheetBehavior} associated with the {@code view}. */ @NonNull @SuppressWarnings("unchecked") - public static NeoBottomSheetBehavior from(@NonNull V view) { + public static BackportBottomSheetBehavior from(@NonNull V view) { ViewGroup.LayoutParams params = view.getLayoutParams(); if (!(params instanceof CoordinatorLayout.LayoutParams)) { throw new IllegalArgumentException("The view is not a child of CoordinatorLayout"); } CoordinatorLayout.Behavior behavior = ((CoordinatorLayout.LayoutParams) params).getBehavior(); - if (!(behavior instanceof NeoBottomSheetBehavior)) { + if (!(behavior instanceof BackportBottomSheetBehavior)) { throw new IllegalArgumentException("The view is not associated with BottomSheetBehavior"); } - return (NeoBottomSheetBehavior) behavior; + return (BackportBottomSheetBehavior) behavior; } /** @@ -2200,3 +2201,4 @@ public class NeoBottomSheetBehavior extends CoordinatorLayout.Be }; } } + diff --git a/app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java b/app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java new file mode 100644 index 000000000..62960dedc --- /dev/null +++ b/app/src/main/java/com/google/android/material/divider/BackportMaterialDividerItemDecoration.java @@ -0,0 +1,409 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.material.divider; + +import com.google.android.material.R; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.ShapeDrawable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.ItemDecoration; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import androidx.annotation.ColorInt; +import androidx.annotation.ColorRes; +import androidx.annotation.DimenRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.core.view.ViewCompat; +import com.google.android.material.internal.ThemeEnforcement; +import com.google.android.material.resources.MaterialResources; + +/** + * MaterialDividerItemDecoration is a {@link RecyclerView.ItemDecoration}, similar to a {@link + * androidx.recyclerview.widget.DividerItemDecoration}, that can be used as a divider between items of + * a {@link LinearLayoutManager}. It supports both {@link #HORIZONTAL} and {@link #VERTICAL} + * orientations. + * + *
+ *     dividerItemDecoration = new MaterialDividerItemDecoration(recyclerView.getContext(),
+ *             layoutManager.getOrientation());
+ *     recyclerView.addItemDecoration(dividerItemDecoration);
+ * 
+ */ +public class BackportMaterialDividerItemDecoration extends ItemDecoration { + public static final int HORIZONTAL = LinearLayout.HORIZONTAL; + public static final int VERTICAL = LinearLayout.VERTICAL; + + private static final int DEF_STYLE_RES = R.style.Widget_MaterialComponents_MaterialDivider; + + @NonNull private Drawable dividerDrawable; + private int thickness; + @ColorInt private int color; + private int orientation; + private int insetStart; + private int insetEnd; + private boolean lastItemDecorated; + + private final Rect tempRect = new Rect(); + + public BackportMaterialDividerItemDecoration(@NonNull Context context, int orientation) { + this(context, null, orientation); + } + + public BackportMaterialDividerItemDecoration( + @NonNull Context context, @Nullable AttributeSet attrs, int orientation) { + this(context, attrs, R.attr.materialDividerStyle, orientation); + } + + public BackportMaterialDividerItemDecoration( + @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int orientation) { + TypedArray attributes = + ThemeEnforcement.obtainStyledAttributes( + context, attrs, R.styleable.MaterialDivider, defStyleAttr, DEF_STYLE_RES); + + color = + MaterialResources.getColorStateList( + context, attributes, R.styleable.MaterialDivider_dividerColor) + .getDefaultColor(); + thickness = + attributes.getDimensionPixelSize( + R.styleable.MaterialDivider_dividerThickness, + context.getResources().getDimensionPixelSize(R.dimen.material_divider_thickness)); + insetStart = + attributes.getDimensionPixelOffset(R.styleable.MaterialDivider_dividerInsetStart, 0); + insetEnd = attributes.getDimensionPixelOffset(R.styleable.MaterialDivider_dividerInsetEnd, 0); + lastItemDecorated = + attributes.getBoolean(R.styleable.MaterialDivider_lastItemDecorated, true); + + attributes.recycle(); + + dividerDrawable = new ShapeDrawable(); + setDividerColor(color); + setOrientation(orientation); + } + + /** + * Sets the orientation for this divider. This should be called if {@link + * RecyclerView.LayoutManager} changes orientation. + * + *

A {@link #HORIZONTAL} orientation will draw a vertical divider, and a {@link #VERTICAL} + * orientation a horizontal divider. + * + * @param orientation The orientation of the {@link RecyclerView} this divider is associated with: + * {@link #HORIZONTAL} or {@link #VERTICAL} + */ + public void setOrientation(int orientation) { + if (orientation != HORIZONTAL && orientation != VERTICAL) { + throw new IllegalArgumentException( + "Invalid orientation: " + orientation + ". It should be either HORIZONTAL or VERTICAL"); + } + this.orientation = orientation; + } + + public int getOrientation() { + return orientation; + } + + /** + * Sets the thickness of the divider. + * + * @param thickness The thickness value to be set. + * @see #getDividerThickness() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerThickness + */ + public void setDividerThickness(@Px int thickness) { + this.thickness = thickness; + } + + /** + * Sets the thickness of the divider. + * + * @param thicknessId The id of the thickness dimension resource to be set. + * @see #getDividerThickness() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerThickness + */ + public void setDividerThicknessResource(@NonNull Context context, @DimenRes int thicknessId) { + setDividerThickness(context.getResources().getDimensionPixelSize(thicknessId)); + } + + /** + * Returns the thickness set on the divider. + * + * @see #setDividerThickness(int) + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerThickness + */ + @Px + public int getDividerThickness() { + return thickness; + } + + /** + * Sets the color of the divider. + * + * @param color The color to be set. + * @see #getDividerColor() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerColor + */ + public void setDividerColor(@ColorInt int color) { + this.color = color; + dividerDrawable = DrawableCompat.wrap(dividerDrawable); + DrawableCompat.setTint(dividerDrawable, color); + } + + /** + * Sets the color of the divider. + * + * @param colorId The id of the color resource to be set. + * @see #getDividerColor() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerColor + */ + public void setDividerColorResource(@NonNull Context context, @ColorRes int colorId) { + setDividerColor(ContextCompat.getColor(context, colorId)); + } + + /** + * Returns the divider color. + * + * @see #setDividerColor(int) + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerColor + */ + @ColorInt + public int getDividerColor() { + return color; + } + + /** + * Sets the start inset of the divider. + * + * @param insetStart The start inset to be set. + * @see #getDividerInsetStart() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetStart + */ + public void setDividerInsetStart(@Px int insetStart) { + this.insetStart = insetStart; + } + + /** + * Sets the start inset of the divider. + * + * @param insetStartId The id of the inset dimension resource to be set. + * @see #getDividerInsetStart() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetStart + */ + public void setDividerInsetStartResource(@NonNull Context context, @DimenRes int insetStartId) { + setDividerInsetStart(context.getResources().getDimensionPixelOffset(insetStartId)); + } + + /** + * Returns the divider's start inset. + * + * @see #setDividerInsetStart(int) + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetStart + */ + @Px + public int getDividerInsetStart() { + return insetStart; + } + + /** + * Sets the end inset of the divider. + * + * @param insetEnd The end inset to be set. + * @see #getDividerInsetEnd() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetEnd + */ + public void setDividerInsetEnd(@Px int insetEnd) { + this.insetEnd = insetEnd; + } + + /** + * Sets the end inset of the divider. + * + * @param insetEndId The id of the inset dimension resource to be set. + * @see #getDividerInsetEnd() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetEnd + */ + public void setDividerInsetEndResource(@NonNull Context context, @DimenRes int insetEndId) { + setDividerInsetEnd(context.getResources().getDimensionPixelOffset(insetEndId)); + } + + /** + * Returns the divider's end inset. + * + * @see #setDividerInsetEnd(int) + * @attr ref com.google.android.material.R.styleable#MaterialDivider_dividerInsetEnd + */ + @Px + public int getDividerInsetEnd() { + return insetEnd; + } + + /** + * Sets whether the class should draw a divider after the last item of a {@link RecyclerView}. + * + * @param lastItemDecorated whether there's a divider after the last item of a recycler view. + * @see #isLastItemDecorated() + * @attr ref com.google.android.material.R.styleable#MaterialDivider_lastItemDecorated + */ + public void setLastItemDecorated(boolean lastItemDecorated) { + this.lastItemDecorated = lastItemDecorated; + } + + /** + * Whether there's a divider after the last item of a {@link RecyclerView}. + * + * @see #setLastItemDecorated(boolean) + * @attr ref com.google.android.material.R.styleable#MaterialDivider_shouldDecorateLastItem + */ + public boolean isLastItemDecorated() { + return lastItemDecorated; + } + + @Override + public void onDraw( + @NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { + if (parent.getLayoutManager() == null) { + return; + } + if (orientation == VERTICAL) { + drawForVerticalOrientation(canvas, parent); + } else { + drawForHorizontalOrientation(canvas, parent); + } + } + + /** + * Draws a divider for the vertical orientation of the recycler view. The divider itself will be + * horizontal. + */ + private void drawForVerticalOrientation(@NonNull Canvas canvas, @NonNull RecyclerView parent) { + canvas.save(); + int left; + int right; + if (parent.getClipToPadding()) { + left = parent.getPaddingLeft(); + right = parent.getWidth() - parent.getPaddingRight(); + canvas.clipRect( + left, parent.getPaddingTop(), right, parent.getHeight() - parent.getPaddingBottom()); + } else { + left = 0; + right = parent.getWidth(); + } + boolean isRtl = ViewCompat.getLayoutDirection(parent) == ViewCompat.LAYOUT_DIRECTION_RTL; + left += isRtl ? insetEnd : insetStart; + right -= isRtl ? insetStart : insetEnd; + + int childCount = parent.getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = parent.getChildAt(i); + if (shouldDrawDivider(parent, child)) { + parent.getDecoratedBoundsWithMargins(child, tempRect); + // Take into consideration any translationY added to the view. + int bottom = tempRect.bottom + Math.round(child.getTranslationY()); + int top = bottom - thickness; + dividerDrawable.setBounds(left, top, right, bottom); + dividerDrawable.draw(canvas); + } + } + canvas.restore(); + } + + /** + * Draws a divider for the horizontal orientation of the recycler view. The divider itself will be + * vertical. + */ + private void drawForHorizontalOrientation(@NonNull Canvas canvas, @NonNull RecyclerView parent) { + canvas.save(); + int top; + int bottom; + if (parent.getClipToPadding()) { + top = parent.getPaddingTop(); + bottom = parent.getHeight() - parent.getPaddingBottom(); + canvas.clipRect( + parent.getPaddingLeft(), top, parent.getWidth() - parent.getPaddingRight(), bottom); + } else { + top = 0; + bottom = parent.getHeight(); + } + top += insetStart; + bottom -= insetEnd; + + int childCount = parent.getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = parent.getChildAt(i); + if (shouldDrawDivider(parent, child)) { + parent.getDecoratedBoundsWithMargins(child, tempRect); + // Take into consideration any translationX added to the view. + int right = tempRect.right + Math.round(child.getTranslationX()); + int left = right - thickness; + dividerDrawable.setBounds(left, top, right, bottom); + dividerDrawable.draw(canvas); + } + } + canvas.restore(); + } + + @Override + public void getItemOffsets( + @NonNull Rect outRect, + @NonNull View view, + @NonNull RecyclerView parent, + @NonNull RecyclerView.State state) { + outRect.set(0, 0, 0, 0); + // Only add offset if there's a divider displayed. + if (shouldDrawDivider(parent, view)) { + if (orientation == VERTICAL) { + outRect.bottom = thickness; + } else { + outRect.right = thickness; + } + } + } + + private boolean shouldDrawDivider(@NonNull RecyclerView parent, @NonNull View child) { + int position = parent.getChildAdapterPosition(child); + RecyclerView.Adapter adapter = parent.getAdapter(); + boolean isLastItem = adapter != null && position == adapter.getItemCount() - 1; + + return position != RecyclerView.NO_POSITION + && (!isLastItem || lastItemDecorated) + && shouldDrawDivider(position, adapter); + } + + /** + * Whether a divider should be drawn below the current item that is being drawn. + * + *

Note: if lasItemDecorated is false, the divider below the last item will never be drawn even + * if this method returns true. + * + * @param position the position of the current item being drawn. + * @param adapter the {@link RecyclerView.Adapter} associated with the item being drawn. + */ + protected boolean shouldDrawDivider(int position, @Nullable RecyclerView.Adapter adapter) { + return true; + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 794354aef..8b93763f5 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -30,7 +30,7 @@ import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior +import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.transition.MaterialFadeThrough import kotlin.math.max @@ -101,10 +101,10 @@ class MainFragment : val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior unlikelyToBeNull(binding.handleWrapper).setOnClickListener { - if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED && - queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED) { + if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED && + queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) { // Playback sheet is expanded and queue sheet is collapsed, we can expand it. - queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED + queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED } } } else { @@ -183,7 +183,7 @@ class MainFragment : // Playback sheet intercepts queue sheet touch events, prevent that from // occurring by disabling dragging whenever the queue sheet is expanded. playbackSheetBehavior.isDraggable = - queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED + queueSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED } } else { // No queue sheet, fade normally based on the playback sheet @@ -317,9 +317,9 @@ class MainFragment : val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED) { + if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_COLLAPSED) { // Playback sheet is not expanded and not hidden, we can expand it. - playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED + playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED } } @@ -327,12 +327,12 @@ class MainFragment : val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) { + if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { // Make sure the queue is also collapsed here. val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? - playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED - queueSheetBehavior?.state = NeoBottomSheetBehavior.STATE_COLLAPSED + playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED + queueSheetBehavior?.state = BackportBottomSheetBehavior.STATE_COLLAPSED } } @@ -340,7 +340,7 @@ class MainFragment : val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_HIDDEN) { + if (playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_HIDDEN) { val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? // Queue sheet behavior is either collapsed or expanded, no hiding needed @@ -348,7 +348,7 @@ class MainFragment : playbackSheetBehavior.apply { // Make sure the view is draggable, at least until the draw checks kick in. isDraggable = true - state = NeoBottomSheetBehavior.STATE_COLLAPSED + state = BackportBottomSheetBehavior.STATE_COLLAPSED } } } @@ -357,19 +357,19 @@ class MainFragment : val binding = requireBinding() val playbackSheetBehavior = binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior - if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) { + if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) { val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? // Make both bottom sheets non-draggable so the user can't halt the hiding event. queueSheetBehavior?.apply { isDraggable = false - state = NeoBottomSheetBehavior.STATE_COLLAPSED + state = BackportBottomSheetBehavior.STATE_COLLAPSED } playbackSheetBehavior.apply { isDraggable = false - state = NeoBottomSheetBehavior.STATE_HIDDEN + state = BackportBottomSheetBehavior.STATE_HIDDEN } } } @@ -388,16 +388,16 @@ class MainFragment : // If expanded, collapse the queue sheet first. if (queueSheetBehavior != null && - queueSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED && - playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) { - queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED + queueSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED && + playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED) { + queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED return } // If expanded, collapse the playback sheet next. - if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED && - playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) { - playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED + if (playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_COLLAPSED && + playbackSheetBehavior.state != BackportBottomSheetBehavior.STATE_HIDDEN) { + playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED return } @@ -428,8 +428,8 @@ class MainFragment : val exploreNavController = binding.exploreNavHost.findNavController() isEnabled = - queueSheetBehavior?.state == NeoBottomSheetBehavior.STATE_EXPANDED || - playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED || + queueSheetBehavior?.state == BackportBottomSheetBehavior.STATE_EXPANDED || + playbackSheetBehavior.state == BackportBottomSheetBehavior.STATE_EXPANDED || selectionModel.selected.value.isNotEmpty() || exploreNavController.currentDestination?.id != exploreNavController.graph.startDestinationId diff --git a/app/src/main/java/org/oxycblt/auxio/list/Data.kt b/app/src/main/java/org/oxycblt/auxio/list/Data.kt index b324fdc09..dc7741062 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Data.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Data.kt @@ -27,4 +27,4 @@ interface Item * @param titleRes The string resource used for the header's title. * @param withDivider Whether to show a divider on the item. */ -data class Header(@StringRes val titleRes: Int, val withDivider: Boolean = true) : Item +data class Header(@StringRes val titleRes: Int) : Item diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt index b29e8cb7f..06eea4411 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt @@ -45,6 +45,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr // Auxio's non-dialog RecyclerViews never change their size based on adapter contents, // so we can enable fixed-size optimizations. setHasFixedSize(true) + addItemDecoration(HeaderItemDecoration(context)) } final override fun setHasFixedSize(hasFixedSize: Boolean) { @@ -52,6 +53,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr super.setHasFixedSize(hasFixedSize) } + final override fun addItemDecoration(decor: ItemDecoration) { + super.addItemDecoration(decor) + } + override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { // Update the RecyclerView's padding such that the bottom insets are applied // while still preserving bottom padding. @@ -78,6 +83,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr } } + /** A [RecyclerView.Adapter]-specific hook to control divider decoration visibility. */ + /** An [RecyclerView.Adapter]-specific hook to [GridLayoutManager.SpanSizeLookup]. */ interface SpanSizeLookup { /** diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt new file mode 100644 index 000000000..6a958321e --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.list.recycler + +import android.content.Context +import android.util.AttributeSet +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.divider.BackportMaterialDividerItemDecoration +import org.oxycblt.auxio.R +import org.oxycblt.auxio.list.Header + +class HeaderItemDecoration +@JvmOverloads +constructor( + context: Context, + attributeSet: AttributeSet? = null, + defStyleAttr: Int = R.attr.materialDividerStyle, + orientation: Int = LinearLayoutManager.VERTICAL +) : BackportMaterialDividerItemDecoration(context, attributeSet, defStyleAttr, orientation) { + override fun shouldDrawDivider(position: Int, adapter: RecyclerView.Adapter<*>?) = + try { + (adapter as DiffAdapter<*, *, *>).getItem(position + 1) is Header + } catch (e: ClassCastException) { + false + } catch (e: IndexOutOfBoundsException) { + false + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index 9c559184f..ef4075b67 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -18,7 +18,6 @@ package org.oxycblt.auxio.list.recycler import android.view.View -import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R @@ -31,6 +30,7 @@ import org.oxycblt.auxio.music.* import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.inflater +import org.oxycblt.auxio.util.logD /** * A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance. @@ -249,8 +249,8 @@ class HeaderViewHolder private constructor(private val binding: ItemHeaderBindin * @param header The new [Header] to bind. */ fun bind(header: Header) { + logD(binding.context.getString(header.titleRes)) binding.title.text = binding.context.getString(header.titleRes) - binding.headerDivider.isVisible = header.withDivider } companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index 3c9d0a77e..0bf1ff72a 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -22,6 +22,7 @@ import androidx.recyclerview.widget.RecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.recycler.* import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.util.logD /** * An adapter that displays search results. @@ -54,6 +55,7 @@ class SearchAdapter(private val listener: SelectableListListener) : } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + logD(position) when (val item = getItem(position)) { is Song -> (holder as SongViewHolder).bind(item, listener) is Album -> (holder as AlbumViewHolder).bind(item, listener) @@ -65,7 +67,16 @@ class SearchAdapter(private val listener: SelectableListListener) : override fun isItemFullWidth(position: Int) = getItem(position) is Header + /** + * Make sure that the top header has a correctly configured divider visibility. This would + * normally be automatically done by the differ, but that results in a strange animation. + */ + fun pokeDividers() { + notifyItemChanged(0, PAYLOAD_UPDATE_DIVIDER) + } + private companion object { + val PAYLOAD_UPDATE_DIVIDER = 102249124 /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = object : SimpleItemCallback() { diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index 1d8f8f908..6348419dc 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -159,6 +159,7 @@ class SearchFragment : ListFragment() { // the query actually changes instead of once every re-creation event, but sadly // that doesn't seem possible. binding.searchRecycler.scrollToPosition(0) + searchAdapter.pokeDividers() } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 1f52bdae9..9341a7390 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -110,21 +110,21 @@ class SearchViewModel(application: Application) : if (filterMode == null || filterMode == MusicMode.ARTISTS) { library.artists.searchListImpl(query)?.let { - results.add(Header(R.string.lbl_artists, withDivider = results.isNotEmpty())) + results.add(Header(R.string.lbl_artists)) results.addAll(sort.artists(it)) } } if (filterMode == null || filterMode == MusicMode.ALBUMS) { library.albums.searchListImpl(query)?.let { - results.add(Header(R.string.lbl_albums, withDivider = results.isNotEmpty())) + results.add(Header(R.string.lbl_albums)) results.addAll(sort.albums(it)) } } if (filterMode == null || filterMode == MusicMode.GENRES) { library.genres.searchListImpl(query)?.let { - results.add(Header(R.string.lbl_genres, withDivider = results.isNotEmpty())) + results.add(Header(R.string.lbl_genres)) results.addAll(sort.genres(it)) } } @@ -133,7 +133,7 @@ class SearchViewModel(application: Application) : library.songs .searchListImpl(query) { q, song -> song.path.name.contains(q) } ?.let { - results.add(Header(R.string.lbl_songs, withDivider = results.isNotEmpty())) + results.add(Header(R.string.lbl_songs)) results.addAll(sort.songs(it)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/ui/BasePreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/ui/BasePreferenceFragment.kt index e71431ada..34e396d23 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/ui/BasePreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/ui/BasePreferenceFragment.kt @@ -87,6 +87,7 @@ abstract class BasePreferenceFragment(@XmlRes private val screen: Int) : ) = super.onCreateRecyclerView(inflater, parent, savedInstanceState).apply { clipToPadding = false + addItemDecoration(PreferenceHeaderItemDecoration(context)) setOnApplyWindowInsetsListener { _, insets -> updatePadding(bottom = insets.systemBarInsetsCompat.bottom) insets diff --git a/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt b/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt new file mode 100644 index 000000000..bd27fe0ff --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/settings/ui/PreferenceHeaderItemDecoration.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.settings.ui + +import android.content.Context +import android.util.AttributeSet +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceGroupAdapter +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.divider.BackportMaterialDividerItemDecoration +import org.oxycblt.auxio.R +import org.oxycblt.auxio.util.logD + +class PreferenceHeaderItemDecoration +@JvmOverloads +constructor( + context: Context, + attributeSet: AttributeSet? = null, + defStyleAttr: Int = R.attr.materialDividerStyle, + orientation: Int = LinearLayoutManager.VERTICAL +) : BackportMaterialDividerItemDecoration(context, attributeSet, defStyleAttr, orientation) { + override fun shouldDrawDivider(position: Int, adapter: RecyclerView.Adapter<*>?) = + try { + logD(position) + (adapter as PreferenceGroupAdapter).getItem(position) is PreferenceCategory + } catch (e: ClassCastException) { + false + } catch (e: IndexOutOfBoundsException) { + false + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt b/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt index 1a7a9ec89..aefcedd1e 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/BaseBottomSheetBehavior.kt @@ -24,7 +24,7 @@ import android.view.View import android.view.ViewGroup import android.view.WindowInsets import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior +import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import org.oxycblt.auxio.R import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.systemGestureInsetsCompat @@ -37,7 +37,7 @@ import org.oxycblt.auxio.util.systemGestureInsetsCompat * @author Alexander Capehart (OxygenCobalt) */ abstract class BaseBottomSheetBehavior(context: Context, attributeSet: AttributeSet?) : - NeoBottomSheetBehavior(context, attributeSet) { + BackportBottomSheetBehavior(context, attributeSet) { private var initalized = false init { diff --git a/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt index dfd1d2d25..77f1cfc01 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentBehavior.kt @@ -22,7 +22,7 @@ import android.util.AttributeSet import android.view.View import android.view.WindowInsets import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.google.android.material.bottomsheet.NeoBottomSheetBehavior +import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import kotlin.math.abs import org.oxycblt.auxio.util.coordinatorLayoutBehavior import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat @@ -42,7 +42,7 @@ class BottomSheetContentBehavior(context: Context, attributeSet: Attri private var setup = false override fun layoutDependsOn(parent: CoordinatorLayout, child: V, dependency: View): Boolean { - if (dependency.coordinatorLayoutBehavior is NeoBottomSheetBehavior) { + if (dependency.coordinatorLayoutBehavior is BackportBottomSheetBehavior) { dep = dependency return true } @@ -55,7 +55,7 @@ class BottomSheetContentBehavior(context: Context, attributeSet: Attri child: V, dependency: View ): Boolean { - val behavior = dependency.coordinatorLayoutBehavior as NeoBottomSheetBehavior + val behavior = dependency.coordinatorLayoutBehavior as BackportBottomSheetBehavior val consumed = behavior.calculateConsumedByBar() if (consumed == Int.MIN_VALUE) { return false @@ -87,7 +87,7 @@ class BottomSheetContentBehavior(context: Context, attributeSet: Attri heightUsed: Int ): Boolean { val dep = dep ?: return false - val behavior = dep.coordinatorLayoutBehavior as NeoBottomSheetBehavior + val behavior = dep.coordinatorLayoutBehavior as BackportBottomSheetBehavior val consumed = behavior.calculateConsumedByBar() if (consumed == Int.MIN_VALUE) { return false @@ -106,7 +106,7 @@ class BottomSheetContentBehavior(context: Context, attributeSet: Attri child.setOnApplyWindowInsetsListener { _, insets -> lastInsets = insets val dep = dep ?: return@setOnApplyWindowInsetsListener insets - val behavior = dep.coordinatorLayoutBehavior as NeoBottomSheetBehavior + val behavior = dep.coordinatorLayoutBehavior as BackportBottomSheetBehavior val consumed = behavior.calculateConsumedByBar() if (consumed == Int.MIN_VALUE) { return@setOnApplyWindowInsetsListener insets @@ -138,7 +138,7 @@ class BottomSheetContentBehavior(context: Context, attributeSet: Attri child.layout(0, 0, child.measuredWidth, child.measuredHeight) } - private fun NeoBottomSheetBehavior<*>.calculateConsumedByBar(): Int { + private fun BackportBottomSheetBehavior<*>.calculateConsumedByBar(): Int { val offset = calculateSlideOffset() if (offset == Float.MIN_VALUE || peekHeight < 0) { return Int.MIN_VALUE diff --git a/app/src/main/res/layout/dialog_music_dirs.xml b/app/src/main/res/layout/dialog_music_dirs.xml index 8d234db5f..e9bdb86e4 100644 --- a/app/src/main/res/layout/dialog_music_dirs.xml +++ b/app/src/main/res/layout/dialog_music_dirs.xml @@ -11,6 +11,12 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + app:layout_constraintTop_toBottomOf="@+id/dirs_mode_header_divider" /> - @@ -70,6 +71,13 @@ app:layout_constraintTop_toBottomOf="@+id/folder_mode_group" tools:text="Mode description" /> + + + app:layout_constraintTop_toBottomOf="@+id/dirs_list_header_divider" />