Fix various edge to edge bugs.

This commit is contained in:
Greyson Parrelli
2026-07-28 13:46:48 -04:00
parent a9a8ec7f5d
commit 1b206ddc70
8 changed files with 89 additions and 21 deletions
@@ -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)
}
}
@@ -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) }
@@ -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)));
@@ -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.
* <p>
* 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() {
@@ -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)
}
}
@@ -16,13 +16,15 @@
android:background="@drawable/bottom_toolbar_shadow" />
<LinearLayout
android:id="@+id/emoji_categories_row"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="@color/react_with_any_background"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:minHeight="?actionBarSize"
android:orientation="horizontal">
<FrameLayout
+10
View File
@@ -139,6 +139,13 @@
<item name="fixedRoundedCornerBottomSheetStyle">@style/Widget.Signal.FixedRoundedCorners</item>
<item name="permissionsRationaleDialogTheme">@style/Theme.Signal.AlertDialog.Light.Cornered</item>
<item name="android:forceDarkAllowed" tools:targetApi="29">false</item>
<!--
Off so the framework does not scrim a transparent system bar for legibility, hiding the content we
draw behind it. Set here rather than alongside enableEdgeToEdge() because dialog and bottom sheet
windows inherit this theme, and they are not covered by those calls.
-->
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">false</item>
<item name="android:enforceStatusBarContrast" tools:targetApi="29">false</item>
<!-- Material 3 -->
<item name="colorPrimary">@color/signal_colorPrimary</item>
@@ -235,6 +242,9 @@
<item name="fixedRoundedCornerBottomSheetStyle">@style/Widget.Signal.FixedRoundedCorners</item>
<item name="permissionsRationaleDialogTheme">@style/Theme.Signal.AlertDialog.Dark.Cornered</item>
<item name="android:forceDarkAllowed" tools:targetApi="29">false</item>
<!-- As above: no framework contrast scrim over the transparent system bars. -->
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">false</item>
<item name="android:enforceStatusBarContrast" tools:targetApi="29">false</item>
<!-- Material 3 -->
<item name="colorPrimary">@color/signal_colorPrimary</item>
@@ -261,13 +261,23 @@ public class StickyHeaderGridLayoutManager extends RecyclerView.LayoutManager im
requestLayout();
}
/**
* <p>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