Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.ViewModelProvider
Expand All @@ -44,10 +48,12 @@ import com.ichi2.anki.databinding.FragmentReminderTroubleshootingBinding
import com.ichi2.anki.databinding.ItemTroubleshootingCheckBinding
import com.ichi2.anki.requireAnkiActivity
import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.utils.doOnApplyWindowInsets
import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope
import com.ichi2.anki.utils.ext.onWindowFocusChanged
import com.ichi2.anki.utils.ext.requireParcelable
import com.ichi2.anki.utils.ext.setBackgroundTint
import com.ichi2.anki.utils.renderEdgeToEdge
import com.ichi2.utils.Permissions.openAppNotificationsSettingsScreen
import com.ichi2.utils.Permissions.requestPermissionThroughDialogOrSettings
import com.ichi2.utils.dp
Expand Down Expand Up @@ -100,8 +106,6 @@ class ReminderTroubleshootingFragment : Fragment(R.layout.fragment_reminder_trou
) {
super.onViewCreated(view, savedInstanceState)

// Set up root layout insets if the host of this fragment does not support edge-to-edge
binding.rootLayout.fitsSystemWindows = !host.supportsEdgeToEdge
when (host.toolbarType) {
ScheduleRemindersFragment.ToolbarType.EXTERNAL -> setupExternalActivityToolbar()
ScheduleRemindersFragment.ToolbarType.INTERNAL_COLLAPSIBLE,
Expand All @@ -112,6 +116,32 @@ class ReminderTroubleshootingFragment : Fragment(R.layout.fragment_reminder_trou
setupSummary()
setupTroubleshootingChecks()
setupSettingChangeDetector()
setupContentInsets()
renderEdgeToEdge(host)
}

/**
* Keeps the toolbar and the end of the scrolled content clear of the system bars and any
* display cutout.
*
* The content renders underneath the bottom bar while scrolling.
*
* These listeners are no-ops in hosts which apply the insets to this fragment's container
* and consume them.
*/
private fun setupContentInsets() {
binding.troubleshootingToolbar.doOnApplyWindowInsets { view, insets, initial ->
val bars = insets.getInsets(systemBars() or displayCutout())
view.updatePadding(left = bars.left, right = bars.right)
// Margin must be used to align the icon and the title.
view.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = initial.margins.top + bars.top
}
}
binding.scrollView.doOnApplyWindowInsets { view, insets, initial ->
val bars = insets.getInsets(systemBars() or displayCutout())
view.updatePadding(left = bars.left, right = bars.right, bottom = initial.padding.bottom + bars.bottom)
}
}

private fun setupExternalActivityToolbar() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.annotation.IdRes
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
import androidx.core.view.WindowInsetsCompat.Type.statusBars
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
Expand All @@ -52,8 +54,10 @@ import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
import com.ichi2.anki.snackbar.SnackbarBuilder
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.utils.ConfigAwareSingleFragmentActivity
import com.ichi2.anki.utils.doOnApplyWindowInsets
import com.ichi2.anki.utils.ext.getParcelableCompat
import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope
import com.ichi2.anki.utils.renderEdgeToEdge
import com.ichi2.anki.utils.showDialogFragment
import com.ichi2.anki.withProgress
import com.ichi2.utils.Permissions.openAppNotificationsSettingsScreen
Expand Down Expand Up @@ -88,21 +92,17 @@ class ScheduleRemindersFragment :
* Possible hosts of this fragment. Certain stylistic changes need to be made based on where this
* fragment is opened from / nested within.
*
* TODO: Implement edge-to-edge for Settings and ConfigAwareSingleFragmentActivity.
* Then, remove the supportsEdgeToEdge property below and test this fragment's UI behavior 1) on both small and wide screens,
* 2) with all app display themes, and 3) from all possible locations this fragment can be opened from. In particular,
* make sure there is no weird clipping of the collapsible toolbar content scrim when this fragment is opened from the Settings screen upon scrolling.
* This fragment applies the system bar insets to its own views. Hosts which instead apply the
* insets to this fragment's container ([STUDY_OPTIONS_FRAGMENT] and [STUDY_OPTIONS_FRAME])
* consume them, so that they are not applied a second time here.
*
* @param containerId The XML ID of the container in which this fragment is hosted.
* @param toolbarType The type of toolbar to display for this fragment.
* @param supportsEdgeToEdge Whether the host of this fragment currently supports edge-to-edge rendering.
* The legacy fitsSystemWindows property is deprecated and should be migrated away from.
*/
@Parcelize
enum class FragmentHost(
@IdRes val containerId: Int,
val toolbarType: ToolbarType,
val supportsEdgeToEdge: Boolean,
) : Parcelable {
/**
* App-wide review reminders editing screen accessed via Settings.
Expand All @@ -111,7 +111,6 @@ class ScheduleRemindersFragment :
SETTINGS(
containerId = R.id.settings_container,
toolbarType = ToolbarType.INTERNAL_COLLAPSIBLE,
supportsEdgeToEdge = false,
),

/**
Expand All @@ -121,7 +120,6 @@ class ScheduleRemindersFragment :
STUDY_OPTIONS_FRAGMENT(
containerId = R.id.studyoptions_fragment,
toolbarType = ToolbarType.INTERNAL_NON_COLLAPSIBLE,
supportsEdgeToEdge = true,
),

/**
Expand All @@ -132,7 +130,6 @@ class ScheduleRemindersFragment :
STUDY_OPTIONS_FRAME(
containerId = R.id.studyoptions_frame,
toolbarType = ToolbarType.EXTERNAL,
supportsEdgeToEdge = true,
),

/**
Expand All @@ -143,7 +140,6 @@ class ScheduleRemindersFragment :
STANDALONE_ACTIVITY(
containerId = R.id.fragment_container,
toolbarType = ToolbarType.INTERNAL_NON_COLLAPSIBLE,
supportsEdgeToEdge = false,
),
}

Expand Down Expand Up @@ -185,6 +181,8 @@ class ScheduleRemindersFragment :

override val baseSnackbarBuilder: SnackbarBuilder = {
anchorView = binding.floatingActionButtonAdd
// reposition if the anchor moves, e.g. when the window insets arrive after showing
isAnchorViewLayoutListenerEnabled = true
}

/**
Expand All @@ -207,23 +205,21 @@ class ScheduleRemindersFragment :
) {
super.onViewCreated(view, savedInstanceState)

// Set up root layout insets if the host of this fragment does not support edge-to-edge
if (host.supportsEdgeToEdge) {
binding.rootLayout.fitsSystemWindows = false // No need for legacy insets behavior
} else {
binding.rootLayout.fitsSystemWindows = true // Legacy insets behavior to avoid overlapping the status bar
if (host.toolbarType == ToolbarType.INTERNAL_NON_COLLAPSIBLE) {
// The legacy behavior is broken for the non-collapsible toolbar, also implement a manual workaround for it
setNonCollapsibleToolbarInsets()
}
}

// Set up toolbar
when (host.toolbarType) {
ToolbarType.EXTERNAL -> setupExternalActivityToolbar()
ToolbarType.INTERNAL_COLLAPSIBLE -> setupInternalFragmentToolbar(isCollapsible = true)
ToolbarType.INTERNAL_NON_COLLAPSIBLE -> setupInternalFragmentToolbar(isCollapsible = false)
ToolbarType.INTERNAL_COLLAPSIBLE -> {
setToolbarInsets(includeTop = false)
setupInternalFragmentToolbar(isCollapsible = true)
}
ToolbarType.INTERNAL_NON_COLLAPSIBLE -> {
// fitsSystemWindows is broken for the non-collapsible toolbar: also apply the top inset
setToolbarInsets(includeTop = true)
setupInternalFragmentToolbar(isCollapsible = false)
}
}
setContentInsets()
renderEdgeToEdge(host)

binding.floatingActionButtonAdd.setOnClickListener { addReminder() }
troubleshootingViewModel.state.launchCollectionInLifecycleScope(::setupTroubleshootingSnackbar)
Expand Down Expand Up @@ -332,28 +328,75 @@ class ScheduleRemindersFragment :
* The collapsible and non-collapsible toolbar are both located within the appbar.
* At most one is visible at a time (both are hidden if an external toolbar is being used).
* They must be nested within the same appbar because having more than one appbar causes issues with where
* the second one is rendered on the screen. If edge-to-edge is not implemented for the [FragmentHost] of this fragment
* yet, this fragment's layout and appbar has the fitsSystemWindows attribute set to ensure its
* child toolbar is rendered below the status bar.
* the second one is rendered on the screen. This fragment's layout and appbar has the
* fitsSystemWindows attribute set to ensure its child toolbar is rendered below the status bar.
*
* However, because the collapsible toolbar is before the non-collapsible toolbar in the layout file,
* it consumes the fitsSystemWindows inset first and does not pass any to the non-collapsible toolbar.
* Hence, we manually set the insets of the non-collapsible toolbar when it is visible
* via the modern setOnApplyWindowInsetsListener API. We cannot use this API for both the
* collapsible and non-collapsible toolbars and then omit fitsSystemWindows on the appbar. This is
* via the modern setOnApplyWindowInsetsListener API ([includeTop] = true). We cannot apply the top
* inset like this to the collapsible toolbar and omit fitsSystemWindows on the appbar. This is
* because doing so causes UI glitches within the status bar when the collapsible toolbar transitions
* between its expanded and collapsed states.
* between its expanded and collapsed states. The collapsible toolbar only receives the horizontal
* insets here ([includeTop] = false), keeping its content clear of a display cutout in landscape.
*
* This should only be used for the non-collapsible toolbar if edge-to-edge is not implemented on the host yet.
* This listener also replaces the root `CoordinatorLayout`'s own fitsSystemWindows handling,
* which would otherwise offset the list and the 'add reminder' button by the insets a second
* time: [setContentInsets] already keeps them clear.
*
* In hosts which apply the system bar insets to this fragment's container and consume them
* ([FragmentHost.STUDY_OPTIONS_FRAGMENT] and [FragmentHost.STUDY_OPTIONS_FRAME]), no insets
* reach this fragment, so neither fitsSystemWindows nor this listener has any effect.
*/
private fun setNonCollapsibleToolbarInsets() {
private fun setToolbarInsets(includeTop: Boolean) {
val styleExpandedTitleMarginStart = binding.collapsingToolbarLayout.expandedTitleMarginStart
val styleExpandedTitleMarginEnd = binding.collapsingToolbarLayout.expandedTitleMarginEnd
ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { _, insets ->
val bars = insets.getInsets(statusBars() or displayCutout())
binding.appbar.updatePadding(left = bars.left, top = bars.top, right = bars.right)
val bars = insets.getInsets(systemBars() or displayCutout())
if (includeTop) {
binding.appbar.updatePadding(left = bars.left, top = bars.top, right = bars.right)
} else {
// pad inside the collapsing layout, not the app bar: the collapsed content
// scrim covers the collapsing layout's bounds, and must extend behind a
// display cutout to match the rest of the toolbar
binding.collapsingToolbarLayout.updatePadding(left = bars.left, right = bars.right)
// the expanded title ignores the padding: its margins are from the layout's edges
val isRtl = binding.collapsingToolbarLayout.layoutDirection == View.LAYOUT_DIRECTION_RTL
binding.collapsingToolbarLayout.expandedTitleMarginStart =
styleExpandedTitleMarginStart + if (isRtl) bars.right else bars.left
binding.collapsingToolbarLayout.expandedTitleMarginEnd =
styleExpandedTitleMarginEnd + if (isRtl) bars.left else bars.right
}
insets
}
}

/**
* Keeps the list, the 'no reminders' placeholder and the 'add reminder' button clear of the
* navigation bar and any display cutout.
*
* These listeners are no-ops in hosts which apply the insets to this fragment's container
* and consume them.
*/
private fun setContentInsets() {
binding.recyclerView.doOnApplyWindowInsets { view, insets, initial ->
val bars = insets.getInsets(systemBars() or displayCutout())
view.updatePadding(left = bars.left, right = bars.right, bottom = initial.padding.bottom + bars.bottom)
}
binding.noRemindersPlaceholder.doOnApplyWindowInsets { view, insets, _ ->
val bars = insets.getInsets(systemBars() or displayCutout())
view.updatePadding(left = bars.left, right = bars.right)
}
binding.floatingActionButtonAdd.doOnApplyWindowInsets { view, insets, initial ->
val bars = insets.getInsets(systemBars() or displayCutout())
view.updateLayoutParams<ViewGroup.MarginLayoutParams> {
leftMargin = initial.margins.left + bars.left
rightMargin = initial.margins.right + bars.right
bottomMargin = initial.margins.bottom + bars.bottom
}
}
}

private fun retrieveSubtitle(setSubtitle: (String?) -> Unit) {
viewLifecycleOwner.lifecycleScope.launch {
requireActivity().runCatching {
Expand Down
63 changes: 63 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/utils/Insets.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,25 @@

package com.ichi2.anki.utils

import android.os.Build
import android.view.View
import android.view.Window
import android.view.WindowManager
import androidx.annotation.RequiresApi
import androidx.core.graphics.Insets
import androidx.core.view.RoundedCornerCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.marginBottom
import androidx.core.view.marginLeft
import androidx.core.view.marginRight
import androidx.core.view.marginTop
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.ichi2.anki.R
import com.ichi2.anki.reviewreminders.ScheduleRemindersFragment.FragmentHost

/**
* The radius, in pixels, of the larger of the two bottom corner radii.
Expand Down Expand Up @@ -69,3 +79,56 @@ fun View.doOnApplyWindowInsets(block: (view: View, insets: WindowInsetsCompat, i
insets
}
}

/**
* TODO: remove once all [FragmentHost]s are edge-to-edge.
*
* Renders the host window into a display cutout, removing a black bar.
*
* @see WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
*
* Needed even on API 35+: the app opts out of edge-to-edge enforcement
* (`windowOptOutEdgeToEdgeEnforcement`), so these windows fit the system windows on every
* API level.
*/
internal fun Fragment.renderEdgeToEdge(host: FragmentHost) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
// these hosts call `enableEdgeToEdge`
if (host == FragmentHost.STUDY_OPTIONS_FRAGMENT || host == FragmentHost.STUDY_OPTIONS_FRAME) return
if (host == FragmentHost.SETTINGS && isTwoPaneSettings) return
val window = requireActivity().window

viewLifecycleOwner.lifecycle.addObserver(
object : DefaultLifecycleObserver {
// resume/pause rather than view creation/destruction: when this fragment is replaced
// by one which also renders edge to edge, the new fragment's view is created
// before the old fragment restores the window, but it is resumed afterwards
override fun onResume(owner: LifecycleOwner) {
WindowCompat.setDecorFitsSystemWindows(window, false)
window.setLayoutInDisplayCutoutMode(
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES,
)
}

override fun onPause(owner: LifecycleOwner) {
WindowCompat.setDecorFitsSystemWindows(window, true)
window.setLayoutInDisplayCutoutMode(
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT,
)
}
},
)
}

/**
* Whether the settings screen is showing its two-pane (sw600dp) layout,
* with the headers pane alongside the content pane.
*/
private val Fragment.isTwoPaneSettings: Boolean
get() = requireActivity().findViewById<View>(R.id.lateral_nav_container) != null

@RequiresApi(Build.VERSION_CODES.P)
private fun Window.setLayoutInDisplayCutoutMode(mode: Int) {
if (attributes.layoutInDisplayCutoutMode == mode) return
attributes = attributes.also { it.layoutInDisplayCutoutMode = mode }
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
<ScrollView
android:id="@+id/scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:clipToPadding="false">

<LinearLayout
android:layout_width="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:fitsSystemWindows="true">

<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
Expand Down
Loading
Loading