Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,13 @@
# ItemTouchHelper
This is a copy of the `ItemTouchHelper` class and its helpers from [AndroidX RecyclerView](https://developer.android.com/jetpack/androidx/releases/recyclerview) 1.2.1.
It was modified to support swipe actions that don't remove the item from the list, i.e. our swipe actions "toggle selection", "mark as read/unread", "add/remove star". For those actions the view is animated back into its original position instead of off the screen.
Changes to this class should be limited to the functional changes we need. The aim is to make it easier to rebase on AndroidX's version of `ItemTouchHelper`.
This means…
- we're not converting this class to Kotlin unless AndroidX changes their version,
- we're ignoring warnings generated by unmodified code,
- and we're leaving API checks that could be removed because our minSdkVersion is higher than that of the AndroidX library.

View file

@ -0,0 +1,13 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
dependencies {
api(libs.androidx.recyclerview)
implementation(libs.androidx.annotation)
}
android {
namespace = "app.k9mail.ui.utils.itemtouchhelper"
}

View file

@ -0,0 +1,93 @@
/*
* Copyright 2018 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 app.k9mail.ui.utils.itemtouchhelper;
import android.graphics.Canvas;
import android.os.Build;
import android.view.View;
import androidx.core.view.ViewCompat;
import androidx.recyclerview.R;
import androidx.recyclerview.widget.ItemTouchUIUtil;
import androidx.recyclerview.widget.RecyclerView;
/**
* Package private class to keep implementations. Putting them inside ItemTouchUIUtil makes them
* public API, which is not desired in this case.
*/
class ItemTouchUIUtilImpl implements ItemTouchUIUtil {
static final ItemTouchUIUtil INSTANCE = new ItemTouchUIUtilImpl();
@Override
public void onDraw(Canvas c, RecyclerView recyclerView, View view, float dX, float dY,
int actionState, boolean isCurrentlyActive) {
if (Build.VERSION.SDK_INT >= 21) {
if (isCurrentlyActive) {
Object originalElevation = view.getTag(R.id.item_touch_helper_previous_elevation);
if (originalElevation == null) {
originalElevation = ViewCompat.getElevation(view);
float newElevation = 1f + findMaxElevation(recyclerView, view);
ViewCompat.setElevation(view, newElevation);
view.setTag(R.id.item_touch_helper_previous_elevation, originalElevation);
}
}
}
view.setTranslationX(dX);
view.setTranslationY(dY);
}
private static float findMaxElevation(RecyclerView recyclerView, View itemView) {
final int childCount = recyclerView.getChildCount();
float max = 0;
for (int i = 0; i < childCount; i++) {
final View child = recyclerView.getChildAt(i);
if (child == itemView) {
continue;
}
final float elevation = ViewCompat.getElevation(child);
if (elevation > max) {
max = elevation;
}
}
return max;
}
@Override
public void onDrawOver(Canvas c, RecyclerView recyclerView, View view, float dX, float dY,
int actionState, boolean isCurrentlyActive) {
}
@Override
public void clearView(View view) {
if (Build.VERSION.SDK_INT >= 21) {
final Object tag = view.getTag(R.id.item_touch_helper_previous_elevation);
if (tag instanceof Float) {
ViewCompat.setElevation(view, (Float) tag);
}
view.setTag(R.id.item_touch_helper_previous_elevation, null);
}
view.setTranslationX(0f);
view.setTranslationY(0f);
}
@Override
public void onSelected(View view) {
}
}

View file

@ -0,0 +1,12 @@
# LinearLayoutManager
This is a copy of the `LinearLayoutManager` class and its helpers from [AndroidX RecyclerView](https://developer.android.com/jetpack/androidx/releases/recyclerview) 1.2.1.
It was modified to change the anchor behavior when new items are added to the top of the list. See [#6378](https://github.com/thunderbird/thunderbird-android/pull/6379).
Changes to this class should be limited to the functional changes we need. The aim is to make it easier to rebase on AndroidX's version of `LinearLayoutManager`.
This means…
- we're not converting this class to Kotlin unless AndroidX changes their version,
- and we're ignoring warnings generated by unmodified code.

View file

@ -0,0 +1,13 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
dependencies {
api(libs.androidx.recyclerview)
implementation(libs.androidx.annotation)
}
android {
namespace = "app.k9mail.ui.utils.linearlayoutmanager"
}

View file

@ -0,0 +1,102 @@
package app.k9mail.ui.utils.linearlayoutmanager;
import android.view.View;
import androidx.recyclerview.widget.RecyclerView;
// Source: https://github.com/SchildiChat/SchildiChat-android/blob/a321d6a79a1dc93bcfea442390e49c557285eabe/vector/src/main/java/de/spiritcroc/recyclerview/widget/LayoutManager.java
/**
* Exposing/replicating some internal functions from RecylerView.LayoutManager
*/
public abstract class LayoutManager extends RecyclerView.LayoutManager {
/*
* Exposed things from RecyclerView.java
*/
/**
* The callback used for retrieving information about a RecyclerView and its children in the
* horizontal direction.
*/
private final ViewBoundsCheck.Callback mHorizontalBoundCheckCallback =
new ViewBoundsCheck.Callback() {
@Override
public View getChildAt(int index) {
return LayoutManager.this.getChildAt(index);
}
@Override
public int getParentStart() {
return LayoutManager.this.getPaddingLeft();
}
@Override
public int getParentEnd() {
return LayoutManager.this.getWidth() - LayoutManager.this.getPaddingRight();
}
@Override
public int getChildStart(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
return LayoutManager.this.getDecoratedLeft(view) - params.leftMargin;
}
@Override
public int getChildEnd(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
return LayoutManager.this.getDecoratedRight(view) + params.rightMargin;
}
};
/**
* The callback used for retrieving information about a RecyclerView and its children in the
* vertical direction.
*/
private final ViewBoundsCheck.Callback mVerticalBoundCheckCallback =
new ViewBoundsCheck.Callback() {
@Override
public View getChildAt(int index) {
return LayoutManager.this.getChildAt(index);
}
@Override
public int getParentStart() {
return LayoutManager.this.getPaddingTop();
}
@Override
public int getParentEnd() {
return LayoutManager.this.getHeight()
- LayoutManager.this.getPaddingBottom();
}
@Override
public int getChildStart(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
return LayoutManager.this.getDecoratedTop(view) - params.topMargin;
}
@Override
public int getChildEnd(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
return LayoutManager.this.getDecoratedBottom(view) + params.bottomMargin;
}
};
/**
* Utility objects used to check the boundaries of children against their parent
* RecyclerView.
*
* @see #isViewPartiallyVisible(View, boolean, boolean),
* {@link LinearLayoutManager#findOneVisibleChild(int, int, boolean, boolean)},
* and {@link LinearLayoutManager#findOnePartiallyOrCompletelyInvisibleChild(int, int)}.
*/
ViewBoundsCheck mHorizontalBoundCheck = new ViewBoundsCheck(mHorizontalBoundCheckCallback);
ViewBoundsCheck mVerticalBoundCheck = new ViewBoundsCheck(mVerticalBoundCheckCallback);
}

View file

@ -0,0 +1,105 @@
/*
* Copyright 2018 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 app.k9mail.ui.utils.linearlayoutmanager;
import android.view.View;
import androidx.recyclerview.widget.OrientationHelper;
import androidx.recyclerview.widget.RecyclerView;
/**
* A helper class to do scroll offset calculations.
*/
class ScrollbarHelper {
/**
* @param startChild View closest to start of the list. (top or left)
* @param endChild View closest to end of the list (bottom or right)
*/
static int computeScrollOffset(RecyclerView.State state, OrientationHelper orientation,
View startChild, View endChild, RecyclerView.LayoutManager lm,
boolean smoothScrollbarEnabled, boolean reverseLayout) {
if (lm.getChildCount() == 0 || state.getItemCount() == 0 || startChild == null
|| endChild == null) {
return 0;
}
final int minPosition = Math.min(lm.getPosition(startChild),
lm.getPosition(endChild));
final int maxPosition = Math.max(lm.getPosition(startChild),
lm.getPosition(endChild));
final int itemsBefore = reverseLayout
? Math.max(0, state.getItemCount() - maxPosition - 1)
: Math.max(0, minPosition);
if (!smoothScrollbarEnabled) {
return itemsBefore;
}
final int laidOutArea = Math.abs(orientation.getDecoratedEnd(endChild)
- orientation.getDecoratedStart(startChild));
final int itemRange = Math.abs(lm.getPosition(startChild)
- lm.getPosition(endChild)) + 1;
final float avgSizePerRow = (float) laidOutArea / itemRange;
return Math.round(itemsBefore * avgSizePerRow + (orientation.getStartAfterPadding()
- orientation.getDecoratedStart(startChild)));
}
/**
* @param startChild View closest to start of the list. (top or left)
* @param endChild View closest to end of the list (bottom or right)
*/
static int computeScrollExtent(RecyclerView.State state, OrientationHelper orientation,
View startChild, View endChild, RecyclerView.LayoutManager lm,
boolean smoothScrollbarEnabled) {
if (lm.getChildCount() == 0 || state.getItemCount() == 0 || startChild == null
|| endChild == null) {
return 0;
}
if (!smoothScrollbarEnabled) {
return Math.abs(lm.getPosition(startChild) - lm.getPosition(endChild)) + 1;
}
final int extend = orientation.getDecoratedEnd(endChild)
- orientation.getDecoratedStart(startChild);
return Math.min(orientation.getTotalSpace(), extend);
}
/**
* @param startChild View closest to start of the list. (top or left)
* @param endChild View closest to end of the list (bottom or right)
*/
static int computeScrollRange(RecyclerView.State state, OrientationHelper orientation,
View startChild, View endChild, RecyclerView.LayoutManager lm,
boolean smoothScrollbarEnabled) {
if (lm.getChildCount() == 0 || state.getItemCount() == 0 || startChild == null
|| endChild == null) {
return 0;
}
if (!smoothScrollbarEnabled) {
return state.getItemCount();
}
// smooth scrollbar enabled. try to estimate better.
final int laidOutArea = orientation.getDecoratedEnd(endChild)
- orientation.getDecoratedStart(startChild);
final int laidOutRange = Math.abs(lm.getPosition(startChild)
- lm.getPosition(endChild))
+ 1;
// estimate a size for full list.
return (int) ((float) laidOutArea / laidOutRange * state.getItemCount());
}
private ScrollbarHelper() {
}
}

View file

@ -0,0 +1,269 @@
/*
* Copyright 2018 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 app.k9mail.ui.utils.linearlayoutmanager;
import android.view.View;
import androidx.annotation.IntDef;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* A utility class used to check the boundaries of a given view within its parent view based on
* a set of boundary flags.
*/
class ViewBoundsCheck {
static final int GT = 1 << 0;
static final int EQ = 1 << 1;
static final int LT = 1 << 2;
static final int CVS_PVS_POS = 0;
/**
* The child view's start should be strictly greater than parent view's start.
*/
static final int FLAG_CVS_GT_PVS = GT << CVS_PVS_POS;
/**
* The child view's start can be equal to its parent view's start. This flag follows with GT
* or LT indicating greater (less) than or equal relation.
*/
static final int FLAG_CVS_EQ_PVS = EQ << CVS_PVS_POS;
/**
* The child view's start should be strictly less than parent view's start.
*/
static final int FLAG_CVS_LT_PVS = LT << CVS_PVS_POS;
static final int CVS_PVE_POS = 4;
/**
* The child view's start should be strictly greater than parent view's end.
*/
static final int FLAG_CVS_GT_PVE = GT << CVS_PVE_POS;
/**
* The child view's start can be equal to its parent view's end. This flag follows with GT
* or LT indicating greater (less) than or equal relation.
*/
static final int FLAG_CVS_EQ_PVE = EQ << CVS_PVE_POS;
/**
* The child view's start should be strictly less than parent view's end.
*/
static final int FLAG_CVS_LT_PVE = LT << CVS_PVE_POS;
static final int CVE_PVS_POS = 8;
/**
* The child view's end should be strictly greater than parent view's start.
*/
static final int FLAG_CVE_GT_PVS = GT << CVE_PVS_POS;
/**
* The child view's end can be equal to its parent view's start. This flag follows with GT
* or LT indicating greater (less) than or equal relation.
*/
static final int FLAG_CVE_EQ_PVS = EQ << CVE_PVS_POS;
/**
* The child view's end should be strictly less than parent view's start.
*/
static final int FLAG_CVE_LT_PVS = LT << CVE_PVS_POS;
static final int CVE_PVE_POS = 12;
/**
* The child view's end should be strictly greater than parent view's end.
*/
static final int FLAG_CVE_GT_PVE = GT << CVE_PVE_POS;
/**
* The child view's end can be equal to its parent view's end. This flag follows with GT
* or LT indicating greater (less) than or equal relation.
*/
static final int FLAG_CVE_EQ_PVE = EQ << CVE_PVE_POS;
/**
* The child view's end should be strictly less than parent view's end.
*/
static final int FLAG_CVE_LT_PVE = LT << CVE_PVE_POS;
static final int MASK = GT | EQ | LT;
final Callback mCallback;
BoundFlags mBoundFlags;
/**
* The set of flags that can be passed for checking the view boundary conditions.
* CVS in the flag name indicates the child view, and PV indicates the parent view.\
* The following S, E indicate a view's start and end points, respectively.
* GT and LT indicate a strictly greater and less than relationship.
* Greater than or equal (or less than or equal) can be specified by setting both GT and EQ (or
* LT and EQ) flags.
* For instance, setting both {@link #FLAG_CVS_GT_PVS} and {@link #FLAG_CVS_EQ_PVS} indicate the
* child view's start should be greater than or equal to its parent start.
*/
@IntDef(flag = true, value = {
FLAG_CVS_GT_PVS, FLAG_CVS_EQ_PVS, FLAG_CVS_LT_PVS,
FLAG_CVS_GT_PVE, FLAG_CVS_EQ_PVE, FLAG_CVS_LT_PVE,
FLAG_CVE_GT_PVS, FLAG_CVE_EQ_PVS, FLAG_CVE_LT_PVS,
FLAG_CVE_GT_PVE, FLAG_CVE_EQ_PVE, FLAG_CVE_LT_PVE
})
@Retention(RetentionPolicy.SOURCE)
public @interface ViewBounds {}
ViewBoundsCheck(Callback callback) {
mCallback = callback;
mBoundFlags = new BoundFlags();
}
static class BoundFlags {
int mBoundFlags = 0;
int mRvStart, mRvEnd, mChildStart, mChildEnd;
void setBounds(int rvStart, int rvEnd, int childStart, int childEnd) {
mRvStart = rvStart;
mRvEnd = rvEnd;
mChildStart = childStart;
mChildEnd = childEnd;
}
void addFlags(@ViewBounds int flags) {
mBoundFlags |= flags;
}
void resetFlags() {
mBoundFlags = 0;
}
int compare(int x, int y) {
if (x > y) {
return GT;
}
if (x == y) {
return EQ;
}
return LT;
}
boolean boundsMatch() {
if ((mBoundFlags & (MASK << CVS_PVS_POS)) != 0) {
if ((mBoundFlags & (compare(mChildStart, mRvStart) << CVS_PVS_POS)) == 0) {
return false;
}
}
if ((mBoundFlags & (MASK << CVS_PVE_POS)) != 0) {
if ((mBoundFlags & (compare(mChildStart, mRvEnd) << CVS_PVE_POS)) == 0) {
return false;
}
}
if ((mBoundFlags & (MASK << CVE_PVS_POS)) != 0) {
if ((mBoundFlags & (compare(mChildEnd, mRvStart) << CVE_PVS_POS)) == 0) {
return false;
}
}
if ((mBoundFlags & (MASK << CVE_PVE_POS)) != 0) {
if ((mBoundFlags & (compare(mChildEnd, mRvEnd) << CVE_PVE_POS)) == 0) {
return false;
}
}
return true;
}
};
/**
* Returns the first view starting from fromIndex to toIndex in views whose bounds lie within
* its parent bounds based on the provided preferredBoundFlags. If no match is found based on
* the preferred flags, and a nonzero acceptableBoundFlags is specified, the last view whose
* bounds lie within its parent view based on the acceptableBoundFlags is returned. If no such
* view is found based on either of these two flags, null is returned.
* @param fromIndex The view position index to start the search from.
* @param toIndex The view position index to end the search at.
* @param preferredBoundFlags The flags indicating the preferred match. Once a match is found
* based on this flag, that view is returned instantly.
* @param acceptableBoundFlags The flags indicating the acceptable match if no preferred match
* is found. If so, and if acceptableBoundFlags is non-zero, the
* last matching acceptable view is returned. Otherwise, null is
* returned.
* @return The first view that satisfies acceptableBoundFlags or the last view satisfying
* acceptableBoundFlags boundary conditions.
*/
View findOneViewWithinBoundFlags(int fromIndex, int toIndex,
@ViewBounds int preferredBoundFlags,
@ViewBounds int acceptableBoundFlags) {
final int start = mCallback.getParentStart();
final int end = mCallback.getParentEnd();
final int next = toIndex > fromIndex ? 1 : -1;
View acceptableMatch = null;
for (int i = fromIndex; i != toIndex; i += next) {
final View child = mCallback.getChildAt(i);
final int childStart = mCallback.getChildStart(child);
final int childEnd = mCallback.getChildEnd(child);
mBoundFlags.setBounds(start, end, childStart, childEnd);
if (preferredBoundFlags != 0) {
mBoundFlags.resetFlags();
mBoundFlags.addFlags(preferredBoundFlags);
if (mBoundFlags.boundsMatch()) {
// found a perfect match
return child;
}
}
if (acceptableBoundFlags != 0) {
mBoundFlags.resetFlags();
mBoundFlags.addFlags(acceptableBoundFlags);
if (mBoundFlags.boundsMatch()) {
acceptableMatch = child;
}
}
}
return acceptableMatch;
}
/**
* Returns whether the specified view lies within the boundary condition of its parent view.
* @param child The child view to be checked.
* @param boundsFlags The flag against which the child view and parent view are matched.
* @return True if the view meets the boundsFlag, false otherwise.
*/
boolean isViewWithinBoundFlags(View child, @ViewBounds int boundsFlags) {
mBoundFlags.setBounds(mCallback.getParentStart(), mCallback.getParentEnd(),
mCallback.getChildStart(child), mCallback.getChildEnd(child));
if (boundsFlags != 0) {
mBoundFlags.resetFlags();
mBoundFlags.addFlags(boundsFlags);
return mBoundFlags.boundsMatch();
}
return false;
}
/**
* Callback provided by the user of this class in order to retrieve information about child and
* parent boundaries.
*/
interface Callback {
View getChildAt(int index);
int getParentStart();
int getParentEnd();
int getChildStart(View view);
int getChildEnd(View view);
}
}

View file

@ -0,0 +1,17 @@
plugins {
id(ThunderbirdPlugins.Library.android)
}
dependencies {
api(libs.androidx.appcompat)
api(libs.android.material)
api(libs.androidx.coordinatorlayout)
implementation(libs.androidx.annotation)
implementation(libs.androidx.fragment)
implementation(libs.androidx.lifecycle.viewmodel)
}
android {
namespace = "app.k9mail.ui.utils.bottomsheet"
}

View file

@ -0,0 +1,52 @@
package app.k9mail.ui.utils.bottomsheet
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
/**
* Work around the fact that [BottomSheetCallback.onLayout] is not public.
*/
class LayoutAwareBottomSheetBehavior<V : View>(context: Context, attrs: AttributeSet?) :
BottomSheetBehavior<V>(context, attrs) {
private val callbacks = mutableSetOf<LayoutAwareBottomSheetCallback>()
internal fun addBottomSheetCallback(callback: LayoutAwareBottomSheetCallback) {
callbacks.add(callback)
super.addBottomSheetCallback(callback)
}
internal fun removeBottomSheetCallback(callback: LayoutAwareBottomSheetCallback) {
super.removeBottomSheetCallback(callback)
callbacks.remove(callback)
}
override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
val layoutResult = super.onLayoutChild(parent, child, layoutDirection)
for (callback in callbacks) {
callback.onBottomSheetLayout(child)
}
return layoutResult
}
companion object {
fun <V : View> from(view: V): LayoutAwareBottomSheetBehavior<V> {
val params = view.layoutParams
require(params is CoordinatorLayout.LayoutParams) { "The view is not a child of CoordinatorLayout" }
val behavior = params.behavior
require(behavior is LayoutAwareBottomSheetBehavior<*>) {
"The view is not associated with ToolbarBottomSheetBehavior"
}
@Suppress("UNCHECKED_CAST")
return behavior as LayoutAwareBottomSheetBehavior<V>
}
}
}

View file

@ -0,0 +1,13 @@
package app.k9mail.ui.utils.bottomsheet
import android.view.View
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
/**
* Extends [BottomSheetCallback] so we can notify listeners of layout events.
*
* See [LayoutAwareBottomSheetBehavior].
*/
internal abstract class LayoutAwareBottomSheetCallback : BottomSheetCallback() {
abstract fun onBottomSheetLayout(bottomSheet: View)
}

View file

@ -0,0 +1,422 @@
/*
* Copyright (C) 2023 K-9 Mail contributors
* Copyright (C) 2015 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 app.k9mail.ui.utils.bottomsheet
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.os.Bundle
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.annotation.LayoutRes
import androidx.annotation.StyleRes
import androidx.appcompat.app.AppCompatDialog
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.core.view.doOnLayout
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.color.MaterialColors
import com.google.android.material.shape.MaterialShapeDrawable
/**
* Modal bottom sheet that displays a toolbar when it is fully expanded. Only meant to be used with
* [ToolbarBottomSheetDialogFragment].
*
* Based on [com.google.android.material.bottomsheet.BottomSheetDialog].
*/
class ToolbarBottomSheetDialog internal constructor(context: Context, @StyleRes theme: Int) :
AppCompatDialog(context, getThemeResId(context, theme)) {
private var internalBehavior: LayoutAwareBottomSheetBehavior<FrameLayout>? = null
val behavior: LayoutAwareBottomSheetBehavior<*>
get() {
if (internalBehavior == null) {
ensureContainerAndBehavior()
}
return internalBehavior ?: error("Missing ToolbarBottomSheetBehavior")
}
private var container: FrameLayout? = null
private var coordinator: CoordinatorLayout? = null
private var bottomSheet: FrameLayout? = null
var toolbar: Toolbar? = null
private set
/**
* `true` if dismissing will perform the swipe down animation on the bottom sheet, rather than the window animation
* for the dialog.
*/
var isDismissWithAnimation = false
private var toolbarVisibilityCallback: LayoutAwareBottomSheetCallback? = null
init {
// We hide the title bar for any style configuration. Otherwise, there will be a gap above the bottom sheet
// when it is expanded.
supportRequestWindowFeature(Window.FEATURE_NO_TITLE)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setCancelable(true)
val window = window
if (window != null) {
// The status bar should always be transparent because of the window animation.
window.statusBarColor = Color.TRANSPARENT
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
if (VERSION.SDK_INT < VERSION_CODES.M) {
// It can be transparent for API 23 and above because we will handle switching the status
// bar icons to light or dark as appropriate. For API 21 and API 22 we just set the
// translucent status bar.
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
}
window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
}
override fun setContentView(@LayoutRes layoutResID: Int) {
error("Not supported")
}
override fun setContentView(view: View) {
super.setContentView(wrapInBottomSheet(view))
}
override fun setContentView(view: View, params: ViewGroup.LayoutParams?) {
error("Not supported")
}
override fun onStart() {
super.onStart()
internalBehavior?.let { behavior ->
if (behavior.state == BottomSheetBehavior.STATE_HIDDEN) {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
val window = window
if (window != null) {
container?.fitsSystemWindows = false
coordinator?.fitsSystemWindows = false
val flags = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or flags
}
}
/**
* This function can be called from a few different use cases, including Swiping the dialog down or calling
* `dismiss()` from a `BottomSheetDialogFragment`, tapping outside a dialog, etc
*
* The default animation to dismiss this dialog is a fade-out transition through a
* windowAnimation. Call `setDismissWithAnimation(true)` if you want to utilize the BottomSheet animation
* instead.
*
* If this function is called from a swipe down interaction, or dismissWithAnimation is false,
* then keep the default behavior.
*
* Else, since this is a terminal event which will finish this dialog, we override the attached
* [BottomSheetCallback] to call this function, after [BottomSheetBehavior.STATE_HIDDEN] is set. This
* will enforce the swipe down animation before canceling this dialog.
*/
override fun cancel() {
val behavior = this.behavior
if (!isDismissWithAnimation || behavior.state == BottomSheetBehavior.STATE_HIDDEN) {
super.cancel()
} else {
behavior.setState(BottomSheetBehavior.STATE_HIDDEN)
}
}
private fun ensureContainerAndBehavior(): FrameLayout? {
if (container == null) {
val container = View.inflate(context, R.layout.design_bottom_sheet_dialog, null) as FrameLayout
this.container = container
coordinator = container.findViewById(R.id.coordinator) ?: error("View not found")
val bottomSheet = container.findViewById<FrameLayout>(R.id.design_bottom_sheet) ?: error("View not found")
this.bottomSheet = bottomSheet
toolbar = bottomSheet.findViewById(R.id.toolbar)
val behavior = LayoutAwareBottomSheetBehavior.from(bottomSheet).apply {
addBottomSheetCallback(cancelDialogCallback)
isHideable = true
}
this.internalBehavior = behavior
container.doOnLayout {
behavior.peekHeight = container.height / 2
}
bottomSheet.doOnLayout {
// Don't draw the toolbar underneath the status bar if the bottom sheet doesn't cover the whole screen
// anyway.
if (bottomSheet.width < container.width) {
container.fitsSystemWindows = true
coordinator?.fitsSystemWindows = true
setToolbarVisibilityCallback(topInset = 0)
} else {
container.fitsSystemWindows = false
coordinator?.fitsSystemWindows = false
}
}
}
return container
}
@SuppressLint("ClickableViewAccessibility")
private fun wrapInBottomSheet(view: View): View {
ensureContainerAndBehavior()
val container = checkNotNull(container)
val coordinator = checkNotNull(coordinator)
val bottomSheet = checkNotNull(bottomSheet)
ViewCompat.setOnApplyWindowInsetsListener(bottomSheet) { _, windowInsets ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val topInset = insets.top
setToolbarVisibilityCallback(topInset)
WindowInsetsCompat.CONSUMED
}
while (bottomSheet.childCount > NUMBER_OF_OWN_BOTTOM_SHEET_CHILDREN) {
bottomSheet.removeViewAt(0)
}
bottomSheet.addView(view, 0)
// We treat the CoordinatorLayout as outside the dialog though it is technically inside
coordinator.findViewById<View>(R.id.touch_outside).setOnClickListener {
if (isShowing) {
cancel()
}
}
// Handle accessibility events
ViewCompat.setAccessibilityDelegate(
bottomSheet,
object : AccessibilityDelegateCompat() {
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
super.onInitializeAccessibilityNodeInfo(host, info)
info.addAction(AccessibilityNodeInfoCompat.ACTION_DISMISS)
info.isDismissable = true
}
override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean {
if (action == AccessibilityNodeInfoCompat.ACTION_DISMISS) {
cancel()
return true
}
return super.performAccessibilityAction(host, action, args)
}
},
)
bottomSheet.setOnTouchListener { _, _ ->
// Consume the event and prevent it from falling through
true
}
return container
}
private fun setToolbarVisibilityCallback(topInset: Int) {
val window = checkNotNull(window)
val bottomSheet = checkNotNull(bottomSheet)
val toolbar = checkNotNull(toolbar)
val behavior = checkNotNull(internalBehavior)
toolbarVisibilityCallback?.let { oldCallback ->
behavior.removeBottomSheetCallback(oldCallback)
}
val windowInsetsController = WindowCompat.getInsetsController(window, bottomSheet)
val newCallback = ToolbarVisibilityCallback(windowInsetsController, toolbar, topInset, behavior)
behavior.addBottomSheetCallback(newCallback)
this.toolbarVisibilityCallback = newCallback
}
fun removeDefaultCallback() {
checkNotNull(internalBehavior).removeBottomSheetCallback(cancelDialogCallback)
}
private val cancelDialogCallback: BottomSheetCallback = object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, @BottomSheetBehavior.State newState: Int) {
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
cancel()
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
}
private class ToolbarVisibilityCallback(
private val windowInsetsController: WindowInsetsControllerCompat,
private val toolbar: Toolbar,
private val topInset: Int,
private val behavior: BottomSheetBehavior<FrameLayout>,
) : LayoutAwareBottomSheetCallback() {
private val lightStatusBar: Boolean = windowInsetsController.isAppearanceLightStatusBars
private var lightToolbar = false
init {
// Try to find the background color to automatically change the status bar icons so they will
// still be visible when the bottomsheet slides underneath the status bar.
val toolbarBackground = toolbar.background
val backgroundTint = if (toolbarBackground is MaterialShapeDrawable) {
toolbarBackground.fillColor
} else {
ViewCompat.getBackgroundTintList(toolbar)
}
lightToolbar = if (backgroundTint != null) {
// First check for a tint
MaterialColors.isColorLight(backgroundTint.defaultColor)
} else if (toolbar.background is ColorDrawable) {
// Then check for the background color
MaterialColors.isColorLight((toolbar.background as ColorDrawable).color)
} else {
// Otherwise don't change the status bar color
lightStatusBar
}
}
override fun onStateChanged(bottomSheet: View, newState: Int) {
handleToolbarVisibility(bottomSheet)
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
handleToolbarVisibility(bottomSheet)
}
override fun onBottomSheetLayout(bottomSheet: View) {
handleToolbarVisibility(bottomSheet)
}
private fun handleToolbarVisibility(bottomSheet: View) {
setToolbarState(bottomSheet)
setStatusBarColor(bottomSheet)
}
private fun setToolbarState(bottomSheet: View) {
val top = bottomSheet.top
val collapsedOffset = (bottomSheet.parent as View).height / 2
if (top >= collapsedOffset || behavior.expandedOffset > 0) {
toolbar.isInvisible = true
bottomSheet.setPadding(0, 0, 0, 0)
return
}
val toolbarHeight = toolbar.height - toolbar.paddingTop
val toolbarHeightAndInset = toolbarHeight + topInset
val expandedPercentage =
((collapsedOffset - top).toFloat() / (collapsedOffset - behavior.expandedOffset)).coerceAtMost(1f)
// Add top padding to bottom sheet to make room for the toolbar
val paddingTop = (toolbarHeightAndInset * expandedPercentage).toInt().coerceAtLeast(0)
bottomSheet.setPadding(0, paddingTop, 0, 0)
// Start showing the toolbar when the bottom sheet is a toolbar height away from the top of the screen.
// This value was chosen rather arbitrarily because it looked nice enough.
val toolbarPercentage =
((toolbarHeight - top).toFloat() / (toolbarHeight - behavior.expandedOffset)).coerceAtLeast(0f)
if (toolbarPercentage > 0) {
toolbar.isVisible = true
// Set the toolbar's top padding so the toolbar covers the bottom sheet's whole top padding
val toolbarPaddingTop = (paddingTop - toolbarHeight).coerceAtLeast(0)
toolbar.setPadding(0, toolbarPaddingTop, 0, 0)
// Translate the toolbar view so it is drawn on top of the bottom sheet's top padding
toolbar.translationY = -paddingTop.toFloat()
toolbar.alpha = toolbarPercentage
} else {
toolbar.isInvisible = true
}
}
private fun setStatusBarColor(bottomSheet: View) {
val toolbarLightThreshold = topInset / 2
if (bottomSheet.top < toolbarLightThreshold) {
windowInsetsController.isAppearanceLightStatusBars = lightToolbar
} else if (bottomSheet.top != 0) {
windowInsetsController.isAppearanceLightStatusBars = lightStatusBar
}
}
}
companion object {
private const val NUMBER_OF_OWN_BOTTOM_SHEET_CHILDREN = 1
private fun getThemeResId(context: Context, themeId: Int): Int {
if (themeId != 0) return themeId
val outValue = TypedValue()
val wasAttributeResolved = context.theme.resolveAttribute(
com.google.android.material.R.attr.bottomSheetDialogTheme,
outValue,
true,
)
return if (wasAttributeResolved) {
outValue.resourceId
} else {
error("Missing bottomSheetDialogTheme attribute in theme")
}
}
}
}

View file

@ -0,0 +1,106 @@
/*
* Copyright (C) 2023 K-9 Mail contributors
* Copyright (C) 2015 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 app.k9mail.ui.utils.bottomsheet
import android.app.Dialog
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.appcompat.widget.Toolbar
import com.google.android.material.bottomsheet.BottomSheetBehavior
/**
* Modal bottom sheet that displays a toolbar when it is fully expanded.
*
* Based on [com.google.android.material.bottomsheet.BottomSheetDialogFragment].
*/
open class ToolbarBottomSheetDialogFragment : AppCompatDialogFragment() {
/**
* Tracks if we are waiting for a dismissAllowingStateLoss or a regular dismiss once the BottomSheet is hidden and
* onStateChanged() is called.
*/
private var waitingForDismissAllowingStateLoss = false
val toolbar: Toolbar?
get() = dialog?.toolbar
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return ToolbarBottomSheetDialog(requireContext(), theme)
}
override fun dismiss() {
if (!tryDismissWithAnimation(false)) {
super.dismiss()
}
}
override fun dismissAllowingStateLoss() {
if (!tryDismissWithAnimation(true)) {
super.dismissAllowingStateLoss()
}
}
override fun getDialog(): ToolbarBottomSheetDialog? {
return super.getDialog() as ToolbarBottomSheetDialog?
}
/**
* Tries to dismiss the dialog fragment with the bottom sheet animation. Returns true if possible, false otherwise.
*/
private fun tryDismissWithAnimation(allowingStateLoss: Boolean): Boolean {
val dialog = dialog
if (dialog != null) {
val behavior = dialog.behavior
if (behavior.isHideable && dialog.isDismissWithAnimation) {
dismissWithAnimation(behavior, allowingStateLoss)
return true
}
}
return false
}
private fun dismissWithAnimation(behavior: BottomSheetBehavior<*>, allowingStateLoss: Boolean) {
waitingForDismissAllowingStateLoss = allowingStateLoss
if (behavior.state == BottomSheetBehavior.STATE_HIDDEN) {
dismissAfterAnimation()
} else {
dialog?.removeDefaultCallback()
behavior.addBottomSheetCallback(BottomSheetDismissCallback())
behavior.setState(BottomSheetBehavior.STATE_HIDDEN)
}
}
private fun dismissAfterAnimation() {
if (waitingForDismissAllowingStateLoss) {
super.dismissAllowingStateLoss()
} else {
super.dismiss()
}
}
private inner class BottomSheetDismissCallback : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
dismissAfterAnimation()
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
}
}

View file

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2023 K-9 Mail contributors
~ Copyright (C) 2015 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.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<View
android:id="@+id/touch_outside"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="false"
android:importantForAccessibility="no"
android:soundEffectsEnabled="false"
tools:ignore="UnusedAttribute" />
<FrameLayout
android:id="@+id/design_bottom_sheet"
style="?attr/bottomSheetStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|top"
android:clipChildren="false"
android:clipToPadding="false"
app:layout_behavior="app.k9mail.ui.utils.bottomsheet.LayoutAwareBottomSheetBehavior">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="?attr/toolbarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="invisible" />
</FrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</FrameLayout>