diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt index ecd007a51d..17b442db31 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt @@ -41,6 +41,12 @@ abstract class DSLSettingsFragment( private var toolbar: Toolbar? = null + /** + * Set by layouts that anchor the list to the top of the toolbar rather than below it. Those lists scroll + * behind the toolbar, so they have to carry the status bar inset themselves. + */ + protected open val listScrollsBehindToolbar: Boolean = false + @CallSuper override fun onViewCreated(view: View, savedInstanceState: Bundle?) { toolbar = view.findViewById(R.id.toolbar) @@ -106,8 +112,10 @@ abstract class DSLSettingsFragment( } recyclerView?.let { recycler -> + val insetTypes = WindowInsetsCompat.Type.navigationBars() or if (listScrollsBehindToolbar) WindowInsetsCompat.Type.statusBars() else 0 + recycler.clipToPadding = false - SystemWindowInsetsSetter.attach(recycler, viewLifecycleOwner, WindowInsetsCompat.Type.navigationBars()) + SystemWindowInsetsSetter.attach(recycler, viewLifecycleOwner, insetTypes) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index c4ee0e0d96..2edd372628 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -140,6 +140,8 @@ class ConversationSettingsFragment : menuId = R.menu.conversation_settings ) { + override val listScrollsBehindToolbar: Boolean = true + private val args: ConversationSettingsFragmentArgs by navArgs() private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) } private val alertDisabledTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary_50) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java index 502f82810c..39dec6e967 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/reactions/any/ReactWithAnyEmojiBottomSheetDialogFragment.java @@ -16,6 +16,7 @@ import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.DialogFragment; import androidx.lifecycle.ViewModelProvider; import androidx.loader.app.LoaderManager; @@ -37,6 +38,7 @@ import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageCategoriesAdap import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView; import org.thoughtcrime.securesms.reactions.ReactionsRepository; import org.thoughtcrime.securesms.reactions.edit.EditReactionsActivity; +import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel; @@ -214,6 +216,10 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends FixedRound container.addView(tabBar); + // The tab bar is pinned to the bottom of the dialog window rather than the sheet, so it is not covered by + // the sheet's own inset padding. + SystemWindowInsetsSetter.attach(tabBar.findViewById(R.id.emoji_categories_row), getViewLifecycleOwner(), WindowInsetsCompat.Type.navigationBars()); + emojiPageView.addOnScrollListener(new TopAndBottomShadowHelper(requireView().findViewById(R.id.react_with_any_emoji_top_shadow), tabBar.findViewById(R.id.react_with_any_emoji_bottom_shadow))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FullscreenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/util/FullscreenHelper.java index 36fc3f5140..98db230b80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FullscreenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FullscreenHelper.java @@ -8,6 +8,7 @@ import android.view.Window; import android.view.WindowManager; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.graphics.Insets; import androidx.core.view.DisplayCutoutCompat; import androidx.core.view.ViewCompat; @@ -92,7 +93,7 @@ public final class FullscreenHelper { public void showAndHideWithSystemUI(@NonNull Window window, @NonNull View... views) { ViewCompat.setOnApplyWindowInsetsListener(window.getDecorView(), (view, insets) -> { - boolean hide = !insets.isVisible(WindowInsetsCompat.Type.systemBars()); + boolean hide = !areBarsVisible(insets); for (View target : views) { if (target == null) { @@ -114,7 +115,7 @@ public final class FullscreenHelper { .start(); } - return insets; + return ViewCompat.onApplyWindowInsets(view, insets); }); } @@ -127,8 +128,22 @@ public final class FullscreenHelper { } public boolean isSystemUiVisible() { - WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(activity.getWindow().getDecorView()); - return insets == null || insets.isVisible(WindowInsetsCompat.Type.systemBars()); + return areBarsVisible(ViewCompat.getRootWindowInsets(activity.getWindow().getDecorView())); + } + + /** + * Whether the bars that {@link #showSystemUI()} / {@link #hideSystemUI()} control are currently on screen. + *
+ * Checks the two bars individually rather than {@link WindowInsetsCompat.Type#systemBars()}, which also
+ * covers the caption bar: {@code isVisible} requires every requested type to be visible, and a phone window
+ * has no caption bar source, so the aggregate answer is always "hidden".
+ */
+ private static boolean areBarsVisible(@Nullable WindowInsetsCompat insets) {
+ if (insets == null) {
+ return true;
+ }
+
+ return insets.isVisible(WindowInsetsCompat.Type.statusBars()) || insets.isVisible(WindowInsetsCompat.Type.navigationBars());
}
public void hideSystemUI() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SystemWindowInsetsSetter.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SystemWindowInsetsSetter.kt
index 7ca0f58dee..dd57905644 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/SystemWindowInsetsSetter.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/SystemWindowInsetsSetter.kt
@@ -22,10 +22,14 @@ object SystemWindowInsetsSetter {
}
/**
- * Updates the view whenever a layout occurs to properly account for the system bar insets, added
- * on top of the view's original padding ([ApplyMode.PADDING]) or margin ([ApplyMode.MARGIN]).
- * This is safe to call repeatedly because it only triggers an extra layout pass IF the applied
- * values actually changed.
+ * Accounts for the system bar insets by adding them on top of the view's original padding
+ * ([ApplyMode.PADDING]) or margin ([ApplyMode.MARGIN]).
+ *
+ * Applied from two places. Primarily from the inset dispatch, which runs before measure and layout, so the
+ * first frame is already inset instead of visibly shifting a frame later. That dispatch doesn't reach every
+ * view though (an ancestor may consume the insets first), so each layout re-applies as a fallback, posted
+ * because a layout-time `requestLayout()` is dropped by the framework. Both paths are safe to run
+ * repeatedly: they only trigger another layout if the values actually changed.
*/
@JvmStatic
@JvmOverloads
@@ -42,30 +46,41 @@ object SystemWindowInsetsSetter {
Insets.of(view.paddingLeft, view.paddingTop, view.paddingRight, view.paddingBottom)
}
- val listener = view.doOnEachLayout {
+ val applyInsets = {
val insets = resolveInsets(view, insetType)
val left = base.left + insets.left
val top = base.top + insets.top
val right = base.right + insets.right
val bottom = base.bottom + insets.bottom
- view.post {
- when (applyMode) {
- ApplyMode.PADDING -> view.setPadding(left, top, right, bottom)
- ApplyMode.MARGIN -> {
- val params = view.layoutParams as? ViewGroup.MarginLayoutParams ?: return@post
- if (params.leftMargin != left || params.topMargin != top || params.rightMargin != right || params.bottomMargin != bottom) {
- params.setMargins(left, top, right, bottom)
- view.layoutParams = params
- }
+ when (applyMode) {
+ ApplyMode.PADDING -> view.setPadding(left, top, right, bottom)
+
+ ApplyMode.MARGIN -> {
+ val params = view.layoutParams as? ViewGroup.MarginLayoutParams
+ if (params != null && (params.leftMargin != left || params.topMargin != top || params.rightMargin != right || params.bottomMargin != bottom)) {
+ params.setMargins(left, top, right, bottom)
+ view.layoutParams = params
}
}
}
}
+ ViewCompat.setOnApplyWindowInsetsListener(view) { target, windowInsets ->
+ // Let the view dispatch on down to its children first, then apply ours on top.
+ val result = ViewCompat.onApplyWindowInsets(target, windowInsets)
+ applyInsets()
+ result
+ }
+
+ val listener = view.doOnEachLayout {
+ view.post { applyInsets() }
+ }
+
val lifecycleObserver = object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
view.removeOnLayoutChangeListener(listener)
+ ViewCompat.setOnApplyWindowInsetsListener(view, null)
}
}
diff --git a/app/src/main/res/layout/react_with_any_emoji_tabs.xml b/app/src/main/res/layout/react_with_any_emoji_tabs.xml
index 72e6322435..72ced6cbe1 100644
--- a/app/src/main/res/layout/react_with_any_emoji_tabs.xml
+++ b/app/src/main/res/layout/react_with_any_emoji_tabs.xml
@@ -16,13 +16,15 @@
android:background="@drawable/bottom_toolbar_shadow" />
Rows are laid out, and recycled, against {@code getPaddingTop()} and
+ * {@code getHeight() - getPaddingBottom()}. That is the right boundary while the padding clips, but when it
+ * does not -- the usual edge-to-edge setup, where the list is padded by the navigation bar inset and draws
+ * behind it -- the padded strips are visible, so a row treated as off screen there pops into existence in
+ * plain sight. Extending the boundaries by the padding makes rows scroll through those strips instead.
+ */
private int getExtraLayoutSpace(RecyclerView.State state) {
if (state.hasTargetScrollPosition()) {
return getHeight();
}
- else {
+ else if (getClipToPadding()) {
return 0;
}
+ else {
+ return Math.max(getPaddingTop(), getPaddingBottom());
+ }
}
@Override