@momo-kits/foundation 0.168.1-beta.2 → 0.168.1-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Application/BottomSheet.tsx +142 -37
- package/Application/BottomTab/DuoTabBar.tsx +191 -151
- package/Application/BottomTab/useLiquidSelection.ts +374 -0
- package/Application/Components/DuoGlassBackground.tsx +5 -8
- package/Application/ModalScreen.tsx +12 -8
- package/Application/device.ts +24 -1
- package/Application/utils.tsx +5 -9
- package/Input/InputMoney.tsx +130 -178
- package/Input/styles.ts +9 -0
- package/Layout/Screen.tsx +18 -39
- package/Layout/utils.ts +1 -0
- package/Popup/PopupNotify.tsx +20 -10
- package/Popup/utils.ts +9 -15
- package/package.json +37 -37
package/Input/InputMoney.tsx
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
+
/* eslint-disable react-hooks/exhaustive-deps */
|
|
1
2
|
import React, {
|
|
2
3
|
forwardRef,
|
|
3
4
|
useContext,
|
|
4
5
|
useEffect,
|
|
5
6
|
useImperativeHandle,
|
|
6
|
-
useLayoutEffect,
|
|
7
7
|
useRef,
|
|
8
8
|
useState,
|
|
9
9
|
} from 'react';
|
|
10
10
|
import {
|
|
11
|
+
Platform,
|
|
12
|
+
Pressable,
|
|
13
|
+
Text,
|
|
11
14
|
TextInput,
|
|
12
15
|
TextInputFocusEvent,
|
|
13
|
-
TextInputSelectionChangeEvent,
|
|
14
16
|
TouchableOpacity,
|
|
15
17
|
View,
|
|
16
18
|
} from 'react-native';
|
|
@@ -22,7 +24,6 @@ import {
|
|
|
22
24
|
} from '../Context';
|
|
23
25
|
import { Icon } from '../Icon';
|
|
24
26
|
import { useScaleSize, exportFontFamily } from '../Text';
|
|
25
|
-
import { Spacing } from '../Consts';
|
|
26
27
|
import {
|
|
27
28
|
ErrorView,
|
|
28
29
|
FloatingView,
|
|
@@ -34,93 +35,22 @@ import { InputMoneyProps } from './index';
|
|
|
34
35
|
import styles from './styles';
|
|
35
36
|
import SystemTextInput from './SystemTextInput';
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
type Edit = { start: number; end: number; inserted: string };
|
|
38
|
+
const moneyFormatter = new Intl.NumberFormat('vi-VN');
|
|
39
39
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const caretFromDigitIndex = (display: string, digitIndex: number) => {
|
|
46
|
-
let count = 0;
|
|
47
|
-
for (let i = 0; i < display.length && count < digitIndex; i++) {
|
|
48
|
-
if (/\d/.test(display[i])) {
|
|
49
|
-
count += 1;
|
|
50
|
-
if (count === digitIndex) return i + 1;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return 0;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const detectEdit = (old: string, next: string, selection: Selection): Edit => {
|
|
57
|
-
const { start, end } = selection;
|
|
58
|
-
const removed = old.length - next.length;
|
|
59
|
-
const candidates: Edit[] = [
|
|
60
|
-
{
|
|
61
|
-
start,
|
|
62
|
-
end,
|
|
63
|
-
inserted: next.slice(start, next.length - (old.length - end)),
|
|
64
|
-
},
|
|
65
|
-
{ start: start - removed, end: start, inserted: '' },
|
|
66
|
-
{ start, end: start + removed, inserted: '' },
|
|
67
|
-
];
|
|
68
|
-
const match = candidates.find(
|
|
69
|
-
e =>
|
|
70
|
-
e.start >= 0 &&
|
|
71
|
-
e.end >= e.start &&
|
|
72
|
-
e.end <= old.length &&
|
|
73
|
-
old.slice(0, e.start) + e.inserted + old.slice(e.end) === next,
|
|
74
|
-
);
|
|
75
|
-
if (match) return match;
|
|
76
|
-
|
|
77
|
-
let prefix = 0;
|
|
78
|
-
while (
|
|
79
|
-
prefix < Math.min(old.length, next.length) &&
|
|
80
|
-
old[prefix] === next[prefix]
|
|
81
|
-
) {
|
|
82
|
-
prefix += 1;
|
|
40
|
+
const findEdit = (old: string, text: string) => {
|
|
41
|
+
let start = 0;
|
|
42
|
+
while (start < text.length && old[start] === text[start]) {
|
|
43
|
+
start += 1;
|
|
83
44
|
}
|
|
84
45
|
let suffix = 0;
|
|
85
46
|
while (
|
|
86
|
-
suffix < Math.min(
|
|
87
|
-
old[old.length - 1 - suffix] ===
|
|
47
|
+
suffix < Math.min(text.length, old.length) - start &&
|
|
48
|
+
old[old.length - 1 - suffix] === text[text.length - 1 - suffix]
|
|
88
49
|
) {
|
|
89
50
|
suffix += 1;
|
|
90
51
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
end: old.length - suffix,
|
|
94
|
-
inserted: next.slice(prefix, next.length - suffix),
|
|
95
|
-
};
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const applyEdit = (display: string, edit: Edit, digitLimit: number) => {
|
|
99
|
-
const digits = display.replace(/\D/g, '');
|
|
100
|
-
let left = countDigits(display.slice(0, edit.start));
|
|
101
|
-
let removed = countDigits(display.slice(edit.start, edit.end));
|
|
102
|
-
const inserted = edit.inserted.replace(/\D/g, '');
|
|
103
|
-
if (
|
|
104
|
-
!removed &&
|
|
105
|
-
!inserted &&
|
|
106
|
-
left > 0 &&
|
|
107
|
-
display.slice(edit.start, edit.end).includes('.')
|
|
108
|
-
) {
|
|
109
|
-
left -= 1;
|
|
110
|
-
removed = 1;
|
|
111
|
-
}
|
|
112
|
-
const next = digits.slice(0, left) + inserted + digits.slice(left + removed);
|
|
113
|
-
const trimmed = next.replace(/^0+(?=\d)/, '');
|
|
114
|
-
if ((!removed && !inserted) || trimmed.length > digitLimit) {
|
|
115
|
-
return { digits, caretDigit: left };
|
|
116
|
-
}
|
|
117
|
-
return {
|
|
118
|
-
digits: trimmed,
|
|
119
|
-
caretDigit: Math.max(
|
|
120
|
-
0,
|
|
121
|
-
left + inserted.length - (next.length - trimmed.length),
|
|
122
|
-
),
|
|
123
|
-
};
|
|
52
|
+
const size = old.length + text.length - 2 * (start + suffix);
|
|
53
|
+
return { old, start, suffix, size };
|
|
124
54
|
};
|
|
125
55
|
|
|
126
56
|
const InputMoney = forwardRef(
|
|
@@ -160,14 +90,15 @@ const InputMoney = forwardRef(
|
|
|
160
90
|
const inputFontFamily = exportFontFamily('bold');
|
|
161
91
|
const [focused, setFocused] = useState(false);
|
|
162
92
|
const [displayValue, setDisplayValue] = useState('');
|
|
163
|
-
const [
|
|
164
|
-
const
|
|
165
|
-
const digitsRef = useRef('');
|
|
166
|
-
const selectionRef = useRef<Selection>({ start: 0, end: 0 });
|
|
167
|
-
const pendingCaretRef = useRef<number | null>(null);
|
|
168
|
-
const pendingSelectionRef = useRef<Selection | null>(null);
|
|
169
|
-
const editingRef = useRef(false);
|
|
93
|
+
const [numericValue, setNumericValue] = useState('');
|
|
94
|
+
const [lastDisplayValue, setLastDisplayValue] = useState('');
|
|
170
95
|
const inputRef = useRef<TextInput>(null);
|
|
96
|
+
const caretDigitsAfter = useRef<number | undefined>(undefined);
|
|
97
|
+
const lastRawText = useRef('');
|
|
98
|
+
const [caretSelection, setCaretSelection] = useState<{
|
|
99
|
+
start: number;
|
|
100
|
+
end: number;
|
|
101
|
+
}>();
|
|
171
102
|
const componentName = 'InputMoney';
|
|
172
103
|
|
|
173
104
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
@@ -177,94 +108,103 @@ const InputMoney = forwardRef(
|
|
|
177
108
|
props.accessibilityLabel,
|
|
178
109
|
);
|
|
179
110
|
|
|
180
|
-
const
|
|
111
|
+
const maxLength = (props as any)?.maxLength;
|
|
112
|
+
const hasMaxLength = typeof maxLength === 'number';
|
|
113
|
+
const digitLimit = hasMaxLength ? maxLength : Number.POSITIVE_INFINITY;
|
|
181
114
|
|
|
182
115
|
useEffect(() => {
|
|
183
116
|
if (value !== undefined && typeof value === 'string') {
|
|
184
117
|
const cleanValue = value.replace(/\D/g, '').replace(/^0+(?=\d)/, '');
|
|
185
|
-
if (cleanValue !==
|
|
186
|
-
|
|
118
|
+
if (cleanValue !== numericValue) {
|
|
119
|
+
caretDigitsAfter.current = undefined;
|
|
120
|
+
setNumericValue(cleanValue);
|
|
121
|
+
updateDisplayValue(cleanValue);
|
|
187
122
|
}
|
|
188
123
|
}
|
|
189
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
190
124
|
}, [value]);
|
|
191
125
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
pendingCaretRef.current = null;
|
|
196
|
-
inputRef.current.setSelection(caret, caret);
|
|
197
|
-
}, [commitCount]);
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
setLastDisplayValue(displayValue);
|
|
128
|
+
}, [displayValue]);
|
|
198
129
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
setDisplayValue(
|
|
209
|
-
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!caretSelection) return;
|
|
132
|
+
setCaretSelection(current =>
|
|
133
|
+
current === caretSelection ? undefined : current,
|
|
134
|
+
);
|
|
135
|
+
}, [caretSelection]);
|
|
136
|
+
|
|
137
|
+
const updateDisplayValue = (numeric: string) => {
|
|
138
|
+
const next = numeric ? moneyFormatter.format(Number(numeric)) : '';
|
|
139
|
+
setDisplayValue(next);
|
|
140
|
+
|
|
141
|
+
const digitsAfter = caretDigitsAfter.current;
|
|
142
|
+
caretDigitsAfter.current = undefined;
|
|
143
|
+
if (Platform.OS !== 'android' || digitsAfter === undefined) return;
|
|
144
|
+
let before = parseInputText(next).length - digitsAfter;
|
|
145
|
+
let caret = 0;
|
|
146
|
+
while (before > 0 && caret < next.length) {
|
|
147
|
+
if (/\d/.test(next[caret])) before -= 1;
|
|
148
|
+
caret += 1;
|
|
149
|
+
}
|
|
150
|
+
setCaretSelection({ start: caret, end: caret });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const parseInputText = (text: string): string => text.replace(/\D/g, '');
|
|
154
|
+
|
|
155
|
+
const updateNumericValue = (val: string) => {
|
|
156
|
+
let next = val.replace(/[^\d]/g, '').replace(/^0+(?=\d)/, '');
|
|
157
|
+
if (next.length > digitLimit) next = next.slice(0, digitLimit);
|
|
158
|
+
if (/^\d*$/.test(next)) {
|
|
159
|
+
setNumericValue(next);
|
|
160
|
+
updateDisplayValue(next);
|
|
161
|
+
onChangeText?.(next);
|
|
162
|
+
}
|
|
210
163
|
};
|
|
211
164
|
|
|
212
165
|
const onClearText = () => {
|
|
213
|
-
|
|
166
|
+
inputRef?.current?.clear();
|
|
167
|
+
inputRef.current?.setNativeProps({ text: '' });
|
|
168
|
+
setDisplayValue('');
|
|
169
|
+
setNumericValue('');
|
|
214
170
|
onChangeText?.('');
|
|
215
171
|
};
|
|
216
172
|
|
|
217
173
|
const _onChangeText = (text: string) => {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
});
|
|
225
|
-
if (text) {
|
|
226
|
-
const edit = detectEdit(displayRef.current, text, selectionRef.current);
|
|
227
|
-
({ digits, caretDigit } = applyEdit(
|
|
228
|
-
displayRef.current,
|
|
229
|
-
edit,
|
|
230
|
-
digitLimit,
|
|
231
|
-
));
|
|
174
|
+
if (!text) {
|
|
175
|
+
lastRawText.current = '';
|
|
176
|
+
setDisplayValue('');
|
|
177
|
+
setNumericValue('');
|
|
178
|
+
onChangeText?.('');
|
|
179
|
+
return;
|
|
232
180
|
}
|
|
233
|
-
|
|
234
|
-
|
|
181
|
+
const digits = parseInputText(text);
|
|
182
|
+
const raw = lastRawText.current;
|
|
183
|
+
lastRawText.current = text;
|
|
184
|
+
const formattedEdit = findEdit(lastDisplayValue, text);
|
|
185
|
+
const rawEdit = findEdit(raw, text);
|
|
186
|
+
const { old, start, suffix } =
|
|
187
|
+
raw && rawEdit.size < formattedEdit.size ? rawEdit : formattedEdit;
|
|
188
|
+
caretDigitsAfter.current = parseInputText(
|
|
189
|
+
text.slice(text.length - suffix),
|
|
190
|
+
).length;
|
|
191
|
+
if (text.length < old.length && digits === numericValue) {
|
|
192
|
+
const index = parseInputText(text.slice(0, start)).length - 1;
|
|
193
|
+
if (index >= 0) {
|
|
194
|
+
updateNumericValue(digits.slice(0, index) + digits.slice(index + 1));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
235
197
|
}
|
|
198
|
+
updateNumericValue(digits);
|
|
236
199
|
};
|
|
237
200
|
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const selection = e.nativeEvent.selection;
|
|
242
|
-
pendingSelectionRef.current = selection;
|
|
243
|
-
setTimeout(() => {
|
|
244
|
-
if (pendingSelectionRef.current !== selection) return;
|
|
245
|
-
pendingSelectionRef.current = null;
|
|
246
|
-
const { start, end } = selection;
|
|
247
|
-
const display = displayRef.current;
|
|
248
|
-
const max = display ? display.length - currency.length : 0;
|
|
249
|
-
const clamped = {
|
|
250
|
-
start: Math.min(start, max),
|
|
251
|
-
end: Math.min(end, max),
|
|
252
|
-
};
|
|
253
|
-
selectionRef.current = clamped;
|
|
254
|
-
if (clamped.start !== start || clamped.end !== end) {
|
|
255
|
-
inputRef.current?.setSelection(clamped.start, clamped.end);
|
|
256
|
-
}
|
|
257
|
-
});
|
|
201
|
+
const focusAtEnd = () => {
|
|
202
|
+
inputRef.current?.focus();
|
|
203
|
+
inputRef.current?.setSelection(displayValue.length, displayValue.length);
|
|
258
204
|
};
|
|
259
205
|
|
|
260
206
|
const _onFocus = (e: TextInputFocusEvent) => {
|
|
261
207
|
setFocused(true);
|
|
262
|
-
const caret = pendingCaretRef.current;
|
|
263
|
-
pendingCaretRef.current = null;
|
|
264
|
-
if (caret !== null && !props.selectTextOnFocus) {
|
|
265
|
-
selectionRef.current = { start: caret, end: caret };
|
|
266
|
-
inputRef.current?.setSelection(caret, caret);
|
|
267
|
-
}
|
|
268
208
|
onFocus?.(e);
|
|
269
209
|
};
|
|
270
210
|
|
|
@@ -274,13 +214,15 @@ const InputMoney = forwardRef(
|
|
|
274
214
|
};
|
|
275
215
|
|
|
276
216
|
const _setText = (text: string) => {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
217
|
+
caretDigitsAfter.current = undefined;
|
|
218
|
+
if (!text) {
|
|
219
|
+
setDisplayValue('');
|
|
220
|
+
setNumericValue('');
|
|
221
|
+
onChangeText?.('');
|
|
222
|
+
return;
|
|
283
223
|
}
|
|
224
|
+
const newNumericValue = parseInputText(text);
|
|
225
|
+
updateNumericValue(newNumericValue);
|
|
284
226
|
};
|
|
285
227
|
|
|
286
228
|
useImperativeHandle(ref, () => {
|
|
@@ -302,6 +244,14 @@ const InputMoney = forwardRef(
|
|
|
302
244
|
placeholderColor = disabledColor;
|
|
303
245
|
iconTintColor = disabledColor;
|
|
304
246
|
}
|
|
247
|
+
const textStyle = {
|
|
248
|
+
fontSize: scaledFontSize,
|
|
249
|
+
fontFamily: inputFontFamily,
|
|
250
|
+
};
|
|
251
|
+
const placeholderHasCurrency = placeholder.endsWith(currency);
|
|
252
|
+
const inputPlaceholder = placeholderHasCurrency
|
|
253
|
+
? placeholder.slice(0, -currency.length)
|
|
254
|
+
: placeholder;
|
|
305
255
|
return (
|
|
306
256
|
<View
|
|
307
257
|
style={[
|
|
@@ -320,44 +270,46 @@ const InputMoney = forwardRef(
|
|
|
320
270
|
onPress={onPressFloatingIcon}
|
|
321
271
|
componentId={componentId}
|
|
322
272
|
/>
|
|
323
|
-
<
|
|
273
|
+
<Pressable
|
|
274
|
+
style={[styles.inputView, styles.moneyInputView]}
|
|
275
|
+
onPress={focusAtEnd}
|
|
276
|
+
disabled={disabled}
|
|
277
|
+
>
|
|
324
278
|
<SystemTextInput
|
|
325
279
|
{...props}
|
|
326
280
|
accessibilityLabel={`${componentId}`}
|
|
327
281
|
textAlignVertical="center"
|
|
328
282
|
ref={inputRef}
|
|
329
|
-
editable={props.editable !== false && !disabled}
|
|
330
283
|
accessibilityState={{
|
|
331
284
|
disabled: disabled,
|
|
332
285
|
...props.accessibilityState,
|
|
333
286
|
}}
|
|
334
|
-
style={[
|
|
335
|
-
{
|
|
336
|
-
width: '100%',
|
|
337
|
-
paddingLeft: Spacing.M,
|
|
338
|
-
height: '100%',
|
|
339
|
-
fontSize: scaledFontSize,
|
|
340
|
-
paddingVertical: Spacing.S,
|
|
341
|
-
fontFamily: inputFontFamily,
|
|
342
|
-
},
|
|
343
|
-
{
|
|
344
|
-
color: textColor,
|
|
345
|
-
},
|
|
346
|
-
]}
|
|
287
|
+
style={[textStyle, styles.moneyTextInput, { color: textColor }]}
|
|
347
288
|
allowFontScaling={false}
|
|
348
289
|
textBreakStrategy="highQuality"
|
|
349
290
|
value={displayValue}
|
|
291
|
+
selection={caretSelection ?? props.selection}
|
|
350
292
|
onChangeText={_onChangeText}
|
|
351
|
-
onSelectionChange={_onSelectionChange}
|
|
352
293
|
onFocus={_onFocus}
|
|
353
294
|
onBlur={_onBlur}
|
|
354
|
-
placeholder={
|
|
295
|
+
placeholder={inputPlaceholder}
|
|
355
296
|
selectionColor={theme.colors.primary}
|
|
356
297
|
placeholderTextColor={placeholderColor}
|
|
357
298
|
keyboardType={'numeric'}
|
|
358
299
|
maxLength={undefined}
|
|
359
300
|
/>
|
|
360
|
-
|
|
301
|
+
{displayValue || placeholderHasCurrency ? (
|
|
302
|
+
<Text
|
|
303
|
+
style={[
|
|
304
|
+
textStyle,
|
|
305
|
+
{ color: displayValue ? textColor : placeholderColor },
|
|
306
|
+
]}
|
|
307
|
+
allowFontScaling={false}
|
|
308
|
+
>
|
|
309
|
+
{currency}
|
|
310
|
+
</Text>
|
|
311
|
+
) : null}
|
|
312
|
+
</Pressable>
|
|
361
313
|
<View style={styles.iconView}>
|
|
362
314
|
{showClearIcon && focused && displayValue ? (
|
|
363
315
|
<TouchableOpacity
|
package/Input/styles.ts
CHANGED
|
@@ -218,4 +218,13 @@ export default StyleSheet.create({
|
|
|
218
218
|
marginLeft: Spacing.M,
|
|
219
219
|
},
|
|
220
220
|
debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
|
|
221
|
+
moneyInputView: {
|
|
222
|
+
paddingLeft: Spacing.M,
|
|
223
|
+
},
|
|
224
|
+
moneyTextInput: {
|
|
225
|
+
flexShrink: 1,
|
|
226
|
+
paddingLeft: 0,
|
|
227
|
+
paddingRight: Spacing.XXS,
|
|
228
|
+
paddingVertical: 0,
|
|
229
|
+
},
|
|
221
230
|
});
|
package/Layout/Screen.tsx
CHANGED
|
@@ -64,6 +64,8 @@ import {
|
|
|
64
64
|
import { DuoQuickReturnHeader } from '../Application/Components/DuoQuickReturnHeader';
|
|
65
65
|
import { useDuoTabBarInset } from '../Application/BottomTab/DuoTabBar';
|
|
66
66
|
|
|
67
|
+
const ANIMATED_BANNER_HEIGHT = 210;
|
|
68
|
+
|
|
67
69
|
export interface ScreenProps extends ViewProps {
|
|
68
70
|
/**
|
|
69
71
|
* ReactNode representing the content of the screen.
|
|
@@ -222,19 +224,6 @@ const Screen = forwardRef(
|
|
|
222
224
|
const frame = useSafeAreaFrame();
|
|
223
225
|
const heightHeader = useHeaderHeight();
|
|
224
226
|
|
|
225
|
-
/**
|
|
226
|
-
* Dual animation source:
|
|
227
|
-
* - `animatedValue` is a react-native `Animated.Value` driven natively by the
|
|
228
|
-
* scroll event. It is what legacy consumers receive (`animatedValue`).
|
|
229
|
-
* - `sharedValue` is a reanimated `SharedValue` consumed by every internal
|
|
230
|
-
* foundation component (and offered to modern consumers). It is kept in
|
|
231
|
-
* sync from the scroll `listener` (see below), which fires on the JS
|
|
232
|
-
* thread even when the scroll uses the native driver.
|
|
233
|
-
* If a legacy value is driven outside of Screen, its listener also
|
|
234
|
-
* mirrors JS-driven updates into the shared value.
|
|
235
|
-
* Consumers pass only `Animated.Value`; the reanimated shared value stays
|
|
236
|
-
* an internal implementation detail.
|
|
237
|
-
*/
|
|
238
227
|
const animatedValue = useRef<Animated.Value>(
|
|
239
228
|
customAnimatedValue ?? new Animated.Value(0),
|
|
240
229
|
);
|
|
@@ -258,7 +247,6 @@ const Screen = forwardRef(
|
|
|
258
247
|
const currentTint = useRef<string | undefined>(undefined);
|
|
259
248
|
|
|
260
249
|
const isTab = !!screen?.bottomTab;
|
|
261
|
-
// Duo floating tab bar overlays the tab content instead of taking layout space
|
|
262
250
|
const duoTabInset = useDuoTabBarInset();
|
|
263
251
|
|
|
264
252
|
let handleScroll;
|
|
@@ -267,14 +255,12 @@ const Screen = forwardRef(
|
|
|
267
255
|
const isIOS = Platform.OS === 'ios';
|
|
268
256
|
const bottomInset = isIOS ? Math.min(insets.bottom, 21) : insets.bottom;
|
|
269
257
|
const hasSideBar = !!layout.sideBar;
|
|
270
|
-
// iPhone Duo: content scrolls under a glass header instead of starting below it
|
|
271
258
|
const useDuoGlass =
|
|
272
259
|
hasSideBar &&
|
|
273
260
|
(headerType === 'default' || headerType === 'extended') &&
|
|
274
261
|
!animatedHeader &&
|
|
275
262
|
!inputSearchProps;
|
|
276
263
|
|
|
277
|
-
// iPhone Duo: the header leaves with the content and returns on a reverse scroll
|
|
278
264
|
const useQuickReturn =
|
|
279
265
|
hasSideBar && scrollable && (useDuoGlass || !!animatedHeader);
|
|
280
266
|
const headerHide = useSharedValue(0);
|
|
@@ -324,12 +310,14 @@ const Screen = forwardRef(
|
|
|
324
310
|
*/
|
|
325
311
|
screen?.onSetParams?.(trackingParams);
|
|
326
312
|
|
|
313
|
+
const appliedHeaderMode = useRef<string | undefined>(undefined);
|
|
314
|
+
|
|
327
315
|
/**
|
|
328
316
|
* export options for screen
|
|
329
317
|
* @param headerType
|
|
330
318
|
*/
|
|
331
319
|
const setHeaderType = useCallback(
|
|
332
|
-
(headerType: HeaderType) => {
|
|
320
|
+
(headerType: HeaderType, keepTitle = false) => {
|
|
333
321
|
let options: StackNavigationOptions;
|
|
334
322
|
switch (headerType) {
|
|
335
323
|
case 'none':
|
|
@@ -418,6 +406,10 @@ const Screen = forwardRef(
|
|
|
418
406
|
}
|
|
419
407
|
}
|
|
420
408
|
|
|
409
|
+
if (keepTitle) {
|
|
410
|
+
delete options.headerTitle;
|
|
411
|
+
}
|
|
412
|
+
|
|
421
413
|
navigation?.instance?.setOptions({ header: undefined, ...options });
|
|
422
414
|
},
|
|
423
415
|
[
|
|
@@ -449,10 +441,9 @@ const Screen = forwardRef(
|
|
|
449
441
|
headerTransparent: true,
|
|
450
442
|
headerBackground: (props: any) =>
|
|
451
443
|
hasSideBar ? (
|
|
452
|
-
// glass shows once the banner has scrolled under the header
|
|
453
444
|
<DuoGlassBackground
|
|
454
445
|
animatedValue={sharedValue}
|
|
455
|
-
|
|
446
|
+
bannerBottom={ANIMATED_BANNER_HEIGHT + layoutOffset}
|
|
456
447
|
/>
|
|
457
448
|
) : (
|
|
458
449
|
<HeaderBackground
|
|
@@ -486,7 +477,6 @@ const Screen = forwardRef(
|
|
|
486
477
|
useShadowHeader,
|
|
487
478
|
hasSideBar,
|
|
488
479
|
layoutOffset,
|
|
489
|
-
heightHeader,
|
|
490
480
|
quickReturnOptions,
|
|
491
481
|
],
|
|
492
482
|
);
|
|
@@ -521,7 +511,6 @@ const Screen = forwardRef(
|
|
|
521
511
|
let searchWidth = layout.width - 24;
|
|
522
512
|
let searchLeft = Spacing.M;
|
|
523
513
|
if (layout.sideBar) {
|
|
524
|
-
// back floats in the side bar: the search spans the content width
|
|
525
514
|
searchWidth = layout.contentWidth - Spacing.M * 2;
|
|
526
515
|
searchLeft = Spacing.M - getHeaderMargin(layout.width);
|
|
527
516
|
}
|
|
@@ -563,10 +552,13 @@ const Screen = forwardRef(
|
|
|
563
552
|
|
|
564
553
|
if (animatedHeader) {
|
|
565
554
|
setAnimatedHeader(animatedHeader);
|
|
555
|
+
appliedHeaderMode.current = 'animated';
|
|
566
556
|
} else if (inputSearchProps) {
|
|
567
557
|
setAnimatedSearch();
|
|
558
|
+
appliedHeaderMode.current = 'search';
|
|
568
559
|
} else {
|
|
569
|
-
setHeaderType(headerType);
|
|
560
|
+
setHeaderType(headerType, appliedHeaderMode.current === headerType);
|
|
561
|
+
appliedHeaderMode.current = headerType;
|
|
570
562
|
}
|
|
571
563
|
|
|
572
564
|
return () => {
|
|
@@ -613,7 +605,6 @@ const Screen = forwardRef(
|
|
|
613
605
|
useNativeDriver: true,
|
|
614
606
|
listener: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
615
607
|
const offsetY = e.nativeEvent.contentOffset.y;
|
|
616
|
-
// keep the reanimated source in sync for the internal components.
|
|
617
608
|
sharedValue.value = offsetY;
|
|
618
609
|
scrollViewProps?.onScroll?.(e);
|
|
619
610
|
if (animatedHeader) {
|
|
@@ -648,7 +639,6 @@ const Screen = forwardRef(
|
|
|
648
639
|
*/
|
|
649
640
|
const snapQuickReturnHeader = (offsetY: number) => {
|
|
650
641
|
const hide = headerHide.value;
|
|
651
|
-
// above heightHeader the header still sits on the content
|
|
652
642
|
if (
|
|
653
643
|
!useQuickReturn ||
|
|
654
644
|
offsetY < heightHeader ||
|
|
@@ -692,11 +682,12 @@ const Screen = forwardRef(
|
|
|
692
682
|
if (typeof animatedHeader?.component === 'function') {
|
|
693
683
|
return (
|
|
694
684
|
<View
|
|
695
|
-
style={[
|
|
685
|
+
style={[
|
|
686
|
+
styles.screenBanner,
|
|
687
|
+
{ maxHeight: ANIMATED_BANNER_HEIGHT + layoutOffset },
|
|
688
|
+
]}
|
|
696
689
|
>
|
|
697
690
|
{animatedHeader?.component({
|
|
698
|
-
// legacy consumers read `animatedValue` (Animated.Value),
|
|
699
|
-
// modern consumers read `sharedValue` (SharedValue).
|
|
700
691
|
animatedValue: animatedValue.current,
|
|
701
692
|
sharedValue: sharedValue,
|
|
702
693
|
})}
|
|
@@ -756,11 +747,6 @@ const Screen = forwardRef(
|
|
|
756
747
|
}
|
|
757
748
|
};
|
|
758
749
|
|
|
759
|
-
// Cap the screen container height so a scrollable body with a sticky footer
|
|
760
|
-
// can't push the footer past the visible frame (web previews measure the
|
|
761
|
-
// safe-area frame from the seeded SafeAreaProvider metrics). Subtract the
|
|
762
|
-
// bottom tab bar when present and the header unless it's an animated/extended
|
|
763
|
-
// header (those overlay rather than offset the body).
|
|
764
750
|
const getHeight = () => {
|
|
765
751
|
let newHeight = frame.height;
|
|
766
752
|
if (isTab) {
|
|
@@ -772,8 +758,6 @@ const Screen = forwardRef(
|
|
|
772
758
|
return newHeight;
|
|
773
759
|
};
|
|
774
760
|
|
|
775
|
-
// without the grid, the content container keeps clear of the horizontal insets.
|
|
776
|
-
// Only set when there are insets, so a caller's paddingHorizontal is never overridden.
|
|
777
761
|
const hasContentInset = layout.insetLeft > 0 || layout.insetRight > 0;
|
|
778
762
|
const needsContentInset = !useGridLayout && hasContentInset;
|
|
779
763
|
const contentInsetStyle = needsContentInset && {
|
|
@@ -796,8 +780,6 @@ const Screen = forwardRef(
|
|
|
796
780
|
Styles.flex,
|
|
797
781
|
{
|
|
798
782
|
backgroundColor: backgroundColor ?? theme.colors.background.default,
|
|
799
|
-
// Web-only cap so a scrollable body with a sticky footer can't push
|
|
800
|
-
// the footer past the frame. Native handles this without a cap.
|
|
801
783
|
maxHeight: Platform.OS === 'web' ? getHeight() : undefined,
|
|
802
784
|
},
|
|
803
785
|
]}
|
|
@@ -882,7 +864,6 @@ const Screen = forwardRef(
|
|
|
882
864
|
)}
|
|
883
865
|
|
|
884
866
|
{Footer && (
|
|
885
|
-
// with side insets, the clip keeps the shadow off the side bar, only the top one shows
|
|
886
867
|
<View
|
|
887
868
|
pointerEvents={'box-none'}
|
|
888
869
|
style={[
|
|
@@ -904,8 +885,6 @@ const Screen = forwardRef(
|
|
|
904
885
|
},
|
|
905
886
|
]}
|
|
906
887
|
>
|
|
907
|
-
{/* The footer inset is a caller decision, not a platform one — a footer
|
|
908
|
-
that carries its own horizontal padding passes `marginFooter={false}`. */}
|
|
909
888
|
<Section useMargin={marginFooter}>{Footer}</Section>
|
|
910
889
|
</View>
|
|
911
890
|
</View>
|