1
1
// @flow strict-local
2
- import React , { useCallback , useContext , useEffect , useMemo , useState } from 'react' ;
2
+ import React , { useCallback , useContext , useEffect , useMemo , useRef , useState } from 'react' ;
3
3
import type { Node } from 'react' ;
4
- import { View } from 'react-native' ;
4
+ import { View , Animated , LayoutAnimation , Platform , Easing } from 'react-native' ;
5
5
import NetInfo from '@react-native-community/netinfo' ;
6
6
import { SafeAreaView } from 'react-native-safe-area-context' ;
7
7
import type { ViewProps } from 'react-native/Libraries/Components/View/ViewPropTypes' ;
@@ -10,7 +10,7 @@ import type { DimensionValue } from 'react-native/Libraries/StyleSheet/StyleShee
10
10
import * as logging from '../utils/logging' ;
11
11
import { useGlobalSelector } from '../react-redux' ;
12
12
import { getGlobalSession , getGlobalSettings } from '../directSelectors' ;
13
- import { useHasStayedTrueForMs } from '../reactUtils' ;
13
+ import { useHasStayedTrueForMs , usePrevious } from '../reactUtils' ;
14
14
import type { JSONableDict } from '../utils/jsonable' ;
15
15
import { createStyleSheet } from '../styles' ;
16
16
import ZulipTextIntl from '../common/ZulipTextIntl' ;
@@ -89,7 +89,38 @@ export function OfflineNoticeProvider(props: ProviderProps): Node {
89
89
const theme = useGlobalSelector ( state => getGlobalSettings ( state ) . theme ) ;
90
90
const isOnline = useGlobalSelector ( state => getGlobalSession ( state ) . isOnline ) ;
91
91
const shouldShowUncertaintyNotice = useShouldShowUncertaintyNotice ( ) ;
92
- const isNoticeVisible = isOnline === false || shouldShowUncertaintyNotice ;
92
+
93
+ // Use local UI state for isNoticeVisible instead of computing directly as
94
+ // a `const`, so we can apply LayoutAnimation.configureNext to just the
95
+ // visibility state change, instead of, e.g., all layout changes
96
+ // potentially caused by an APP_ONLINE action.
97
+ const [ isNoticeVisible , setIsNoticeVisible ] = useState (
98
+ isOnline === false || shouldShowUncertaintyNotice ,
99
+ ) ;
100
+
101
+ useEffect ( ( ) => {
102
+ setIsNoticeVisible ( oldValue => {
103
+ const newValue = isOnline === false || shouldShowUncertaintyNotice ;
104
+ if ( oldValue !== newValue ) {
105
+ // Animate the entrance and exit of the offline notice. For how we
106
+ // animate OfflineNoticePlaceholder, see there.
107
+ //
108
+ // For the notice, we shouldn't be affected by the known bad
109
+ // interactions with react-native-screens, because the notice is
110
+ // rootward of all the React Navigation screens in the app. For what
111
+ // those bad interactions are, see the comment in ZulipMobile.js on
112
+ // `UIManager.setLayoutAnimationEnabledExperimental(true)`.
113
+ LayoutAnimation . configureNext ( {
114
+ ...LayoutAnimation . Presets . easeInEaseOut ,
115
+
116
+ // Enter slowly to give bad, possibly unexpected news. Leave quickly
117
+ // to give good, hoped-for news.
118
+ duration : newValue ? 1000 : 300 ,
119
+ } ) ;
120
+ }
121
+ return newValue ;
122
+ } ) ;
123
+ } , [ isOnline , shouldShowUncertaintyNotice ] ) ;
93
124
94
125
const styles = useMemo (
95
126
( ) =>
@@ -99,6 +130,14 @@ export function OfflineNoticeProvider(props: ProviderProps): Node {
99
130
position : 'absolute' ,
100
131
101
132
// Whether the notice is visible or tucked away above the window.
133
+ //
134
+ // (Just as we discovered in 3fa7a7f10 with the lightbox, it seems
135
+ // the Animated API wouldn't let us do a translate-transform
136
+ // animation with a percentage; that's issue
137
+ // https://github.com/facebook/react-native/issues/13107 .
138
+ // So we use LayoutAnimation, which is probably better anyway
139
+ // because it lets us animate layout changes at the native layer,
140
+ // and so won't drop frames when the JavaScript thread is busy.)
102
141
...( isNoticeVisible ? { top : 0 } : { bottom : '100%' } ) ,
103
142
104
143
zIndex : 1 ,
@@ -237,17 +276,60 @@ export function OfflineNoticePlaceholder(props: PlaceholderProps): Node {
237
276
const { style : callerStyle } = props ;
238
277
239
278
const { isNoticeVisible, noticeContentAreaHeight } = useContext ( OfflineNoticeContext ) ;
279
+ const prevIsNoticeVisible = usePrevious ( isNoticeVisible , isNoticeVisible ) ;
280
+
281
+ const plainHeight = isNoticeVisible ? noticeContentAreaHeight : 0 ;
282
+ const animHeight = useRef ( new Animated . Value ( plainHeight ) ) . current ;
283
+
284
+ // Part of an Android workaround; see where we set the View's height.
285
+ useEffect ( ( ) => {
286
+ if ( Platform . OS !== 'android' || prevIsNoticeVisible === isNoticeVisible ) {
287
+ return ;
288
+ }
289
+
290
+ // Should approximate OfflineNoticeProvider's animation curve.
291
+ const animation = Animated . timing ( animHeight , {
292
+ toValue : plainHeight ,
293
+ duration : isNoticeVisible ? 1000 : 300 ,
294
+ easing : Easing . inOut ( t => Easing . ease ( t ) ) ,
295
+
296
+ // With `true`, I get an error:
297
+ // - On Android: "Animated node with tag […] does not exist"
298
+ // - On iOS: "Style property 'height' is not supported by native
299
+ // animated module".
300
+ useNativeDriver : false ,
301
+ } ) ;
302
+
303
+ animation . start ( ) ;
304
+ } , [ isNoticeVisible , prevIsNoticeVisible , plainHeight , animHeight ] ) ;
240
305
241
306
const style = useMemo (
242
307
( ) => [
243
308
{
244
- height : isNoticeVisible ? noticeContentAreaHeight : 0 ,
309
+ height :
310
+ /* prettier-ignore */
311
+ Platform . OS === 'android'
312
+ // Avoid some bad interactions on Android with
313
+ // react-native-screens; see the comment in ZulipMobile.js on
314
+ // `UIManager.setLayoutAnimationEnabledExperimental(true)`. To
315
+ // avoid those, don't pass `plainHeight`, which would cause the
316
+ // resulting layout change to be animated with
317
+ // OfflineNoticeProvider's LayoutAnimation.configureNext call.
318
+ // Instead, use RN's Animated API.
319
+ ? animHeight
320
+ // Do pass `plainHeight`, to piggy-back on
321
+ // OfflineNoticeProvider's LayoutAnimation call. Nothing seems
322
+ // to break this simple use of LayoutAnimation on iOS, and it's
323
+ // better than Animated because Animated can drop animation
324
+ // frames when the CPU is busy (at least without
325
+ // `useNativeDriver: true`, and that doesn't support `height`).
326
+ : plainHeight ,
245
327
width : '100%' ,
246
328
backgroundColor : 'transparent' ,
247
329
} ,
248
330
callerStyle ,
249
331
] ,
250
- [ isNoticeVisible , noticeContentAreaHeight , callerStyle ] ,
332
+ [ plainHeight , animHeight , callerStyle ] ,
251
333
) ;
252
334
253
335
return (
@@ -264,7 +346,9 @@ export function OfflineNoticePlaceholder(props: PlaceholderProps): Node {
264
346
/>
265
347
)
266
348
}
267
- < View style = { style } />
349
+ { /* The `Animated.View` is for the Android workaround; iOS could use a
350
+ regular View. See comment where we set the height attribute. */ }
351
+ < Animated . View style = { style } />
268
352
</ >
269
353
) ;
270
354
}
0 commit comments