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" /> @style/Widget.Signal.FixedRoundedCorners @style/Theme.Signal.AlertDialog.Light.Cornered false + + false + false @color/signal_colorPrimary @@ -235,6 +242,9 @@ @style/Widget.Signal.FixedRoundedCorners @style/Theme.Signal.AlertDialog.Dark.Cornered false + + false + false @color/signal_colorPrimary diff --git a/lib/sticky-header-grid/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridLayoutManager.java b/lib/sticky-header-grid/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridLayoutManager.java index 5390435ed8..85c2b5a38b 100644 --- a/lib/sticky-header-grid/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridLayoutManager.java +++ b/lib/sticky-header-grid/src/main/java/com/codewaves/stickyheadergrid/StickyHeaderGridLayoutManager.java @@ -261,13 +261,23 @@ public class StickyHeaderGridLayoutManager extends RecyclerView.LayoutManager im requestLayout(); } + /** + *

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