forked from software-mansion/react-native-screens
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCustomToolbar.kt
More file actions
249 lines (214 loc) · 10.1 KB
/
CustomToolbar.kt
File metadata and controls
249 lines (214 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
package com.swmansion.rnscreens
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.view.Choreographer
import android.view.WindowInsets
import android.view.WindowManager
import androidx.appcompat.widget.Toolbar
import androidx.core.view.WindowInsetsCompat
import com.facebook.react.modules.core.ReactChoreographer
import com.facebook.react.uimanager.ThemedReactContext
import com.swmansion.rnscreens.utils.InsetsCompat
import com.swmansion.rnscreens.utils.getDecorViewTopInset
import com.swmansion.rnscreens.utils.resolveInsetsOrZero
import kotlin.math.max
/**
* Main toolbar class representing the native header.
*
* This class is used to store config closer to search bar.
* It also handles inset/padding related logic in coordination with header config.
*/
@SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated.
open class CustomToolbar(
context: Context,
val config: ScreenStackHeaderConfig,
) : Toolbar(context) {
// Due to edge-to-edge enforcement starting from Android SDK 35, isTopInsetEnabled prop has been
// removed. Previously, shouldAvoidDisplayCutout, shouldApplyTopInset would directly return the
// value of isTopInsetEnabled. Now, the values of shouldAvoidDisplayCutout, shouldApplyTopInse
// are hard-coded to true (which was the value used previously for isTopInsetEnabled when
// edge-to-edge was enabled: https://github.com/software-mansion/react-native-screens/pull/2464/files#diff-bd1164595b04f44490738b8183f84a625c0e7552a4ae70bfefcdf3bca4d37fc7R34).
private val shouldAvoidDisplayCutout = true
private val shouldApplyTopInset = true
private var lastInsets = InsetsCompat.NONE
private var isForceShadowStateUpdateOnLayoutRequested = false
private var isLayoutEnqueued = false
private var insetsAppliedFromListener = false
init {
// Ensure ActionMenuView is initialized as soon as the Toolbar is created.
//
// Android measures Toolbar height based on the tallest child view.
// During the first measurement:
// 1. The Toolbar is created but not yet added to the action bar via `activity.setSupportActionBar(toolbar)`
// (typically called in `onUpdate` method from `ScreenStackHeaderConfig`).
// 2. At this moment, the title view may exist, but ActionMenuView (which may be taller) hasn't been added yet.
// 3. This causes the initial height calculation to be based on the title view, potentially too small.
// 4. When ActionMenuView is eventually attached, the Toolbar might need to re-layout due to the size change.
//
// By referencing the menu here, we trigger `ensureMenu`, which creates and attaches ActionMenuView early.
// This guarantees that all size-dependent children are present during the first layout pass,
// resulting in correct height determination from the beginning.
menu
}
private val layoutCallback: Choreographer.FrameCallback =
object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
isLayoutEnqueued = false
// The following measure specs are selected to work only with Android APIs <= 29.
// See https://github.com/software-mansion/react-native-screens/pull/2439
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.AT_MOST),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST),
)
layout(left, top, right, bottom)
}
}
override fun requestLayout() {
super.requestLayout()
val softInputMode =
(context as ThemedReactContext)
.currentActivity
?.window
?.attributes
?.softInputMode
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q && softInputMode == WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) {
// Below Android API 29, layout is not being requested when subviews are being added to the layout,
// leading to having their subviews in position 0,0 of the toolbar (as Android don't calculate
// the position of each subview, even if Yoga has correctly set their width and height).
// This is mostly the issue, when windowSoftInputMode is set to adjustPan in AndroidManifest.
// Thus, we're manually calling the layout **after** the current layout.
@Suppress("SENSELESS_COMPARISON") // mLayoutCallback can be null here since this method can be called in init
if (!isLayoutEnqueued && layoutCallback != null) {
isLayoutEnqueued = true
// we use NATIVE_ANIMATED_MODULE choreographer queue because it allows us to catch the current
// looper loop instead of enqueueing the update in the next loop causing a one frame delay.
ReactChoreographer
.getInstance()
.postFrameCallback(
ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE,
layoutCallback,
)
}
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
applyDecorViewTopInsetIfNeeded()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
resetInsetsState()
clearPaddingIfNeeded()
}
private fun applyDecorViewTopInsetIfNeeded() {
if (config.legacyTopInsetBehavior || !config.consumeTopInset || insetsAppliedFromListener) return
val activity = (context as? ThemedReactContext)?.currentActivity ?: return
val decorView = activity.window.decorView
val topInset = getDecorViewTopInset(decorView)
if (topInset > 0) {
applyExactPadding(paddingLeft, topInset, paddingRight, paddingBottom)
}
}
override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets? {
insetsAppliedFromListener = true
val unhandledInsets = super.onApplyWindowInsets(insets)
// There are few UI modes we could be running in
//
// 1. legacy non edge-to-edge mode,
// 2. edge-to-edge with gesture navigation,
// 3. edge-to-edge with translucent navigation buttons bar.
//
// Additionally we need to gracefully handle possible display cutouts.
val cutoutInsets =
resolveInsetsOrZero(WindowInsetsCompat.Type.displayCutout(), unhandledInsets)
val systemBarInsets =
resolveInsetsOrZero(WindowInsetsCompat.Type.systemBars(), unhandledInsets)
// This seems to work fine in all tested configurations, because cutout & system bars overlap
// only in portrait mode & top inset is controlled separately, therefore we don't count
// any insets twice.
val horizontalInsets =
InsetsCompat.of(
cutoutInsets.left + systemBarInsets.left,
0,
cutoutInsets.right + systemBarInsets.right,
0,
)
val shouldHandleTopInset = if (config.legacyTopInsetBehavior) true else config.consumeTopInset
if (!shouldHandleTopInset) {
resetInsetsState()
clearPaddingIfNeeded()
return unhandledInsets
}
// We want to handle display cutout always, no matter the HeaderConfig prop values.
// If there are no cutout displays, we want to apply the additional padding to
// respect the status bar.
//
// Use rootWindowInsets as a fallback for status bar top height when unhandledInsets
// reports 0. This can happen when an ancestor view (e.g. SafeAreaProvider from
// react-native-safe-area-context) has already consumed the top systemBars inset, causing
// unhandledInsets.top == 0 and the toolbar title to render behind the status bar.
// rootWindowInsets always contains the raw window insets regardless of consumption by ancestors.
val statusBarTop = if (shouldApplyTopInset) {
val fromUnhandled = systemBarInsets.top
if (fromUnhandled > 0) fromUnhandled else resolveInsetsOrZero(WindowInsetsCompat.Type.systemBars()).top
} else 0
val verticalInsets =
InsetsCompat.of(
0,
max(cutoutInsets.top, statusBarTop),
0,
max(cutoutInsets.bottom, 0),
)
val newInsets = InsetsCompat.add(horizontalInsets, verticalInsets)
if (lastInsets != newInsets) {
lastInsets = newInsets
applyExactPadding(
lastInsets.left,
lastInsets.top,
lastInsets.right,
lastInsets.bottom,
)
}
return unhandledInsets
}
override fun onLayout(
hasSizeChanged: Boolean,
l: Int,
t: Int,
r: Int,
b: Int,
) {
super.onLayout(hasSizeChanged, l, t, r, b)
config.onNativeToolbarLayout(
this,
hasSizeChanged || isForceShadowStateUpdateOnLayoutRequested,
)
isForceShadowStateUpdateOnLayoutRequested = false
}
fun updateContentInsets() {
contentInsetStartWithNavigation = config.preferredContentInsetStartWithNavigation
setContentInsetsRelative(config.preferredContentInsetStart, config.preferredContentInsetEnd)
}
private fun applyExactPadding(
left: Int,
top: Int,
right: Int,
bottom: Int,
) {
requestForceShadowStateUpdateOnLayout()
setPadding(left, top, right, bottom)
}
private fun resetInsetsState() {
insetsAppliedFromListener = false
lastInsets = InsetsCompat.NONE
}
private fun clearPaddingIfNeeded() {
if (paddingTop != 0 || paddingBottom != 0 || paddingLeft != 0 || paddingRight != 0) {
applyExactPadding(0, 0, 0, 0)
}
}
private fun requestForceShadowStateUpdateOnLayout() {
isForceShadowStateUpdateOnLayoutRequested = shouldAvoidDisplayCutout
}
}