Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Preferred way to load heavy screens #51

Open
brentvatne opened this issue Jun 26, 2018 · 29 comments
Open

Preferred way to load heavy screens #51

brentvatne opened this issue Jun 26, 2018 · 29 comments

Comments

@brentvatne
Copy link
Member

Moved from react-navigation/react-navigation#4578


This is a discussion issue. If you feel it does not go here, please just close it. For me this is the most important point of navigation: Navigate without lag.

I am using in this case react-native-calendars, in their example of course they use react-native-navigation. So I am trying it with react-navigation. This is an example of a "heavy" screen but I am sure there are many others.

This example is as it is (as I'd do it in just native Android):

im_off

This example uses InteractionManager.runAfterInteractions and shows a loading spinner first:

im_on

This next example uses requestAnimationFrame to change the state and make it visible:

raf

All examples are with JS minified in one of the highest end Android devices (Pixel 2).

The first example is definitely laggy (I guess in lower devices even more) and the second and third feel slow to me if you come from checking native apps with similar components like a calendar. I know the limitations so I am asking myself if I can do something better or we are in this point right now?

I know there is this post about State of React Native 2018 and things like new core and async rendering can probably help.

One of my thinkings is that even that React Native docs state:

The views in React Navigation use native components and the Animated library to deliver 60fps animations that are run on the native thread.

So even that you use useNativeDriver as much as possible, that does not really solve the problem. What are your thoughts on this? :)

Btw, I actually opened a similar discussion in wix/react-native-navigation#3432 with the same example using their library (if you are interested).

@brentvatne
Copy link
Member Author

@ferrannp - can you provide a runnable example that you used for the gifs above?

@brentvatne
Copy link
Member Author

also it looks like on the react-native-navigation issue you're looking at transitions on iOS but in this issue you're looking at Android, is there a reason for that difference @ferrannp?

@ferrannp
Copy link

Hey @brentvatne! Thanks for moving this.

First, ok I will provide an example just for this case. I'll prepare the code, push it to a repo and publish it here.

About iOS stuff, not really, I was just using my own device (Android) when checking with react-navigation because with react-native-navigation I was having real big troubles to make it work for both platforms with the latest version of react-native. But anyway, it behaves the same in both platforms.

@brentvatne
Copy link
Member Author

brentvatne commented Jun 26, 2018

this approach works well to selectively render some content before transition, then render the rest after: https://snack.expo.io/rkM0mmgGQ - notice that when the transition starts, 'willFocus' fires but 'didFocus' does not until the transition is completed

we don't yet support giving a screen the opportunity to render before beginning the animation (like in react-native-navigation v2) but this is pretty neat and we probably should come up with a way to make that possible.

@ferrannp
Copy link

ferrannp commented Jun 26, 2018

willFocus to show a loading spinner and didFocus to show the calendar? Will that not be the same as InteractionManager.runAfterInteractions example?

Here you got the calendar: https://snack.expo.io/r1rdvmeGQ. It seems a bit better that my examples? In my app in calendar screen the only difference I have is the withTheme HOC from react-native-paper (and I still need to add a connect from redux there to mark days in calendar).

@brentvatne
Copy link
Member Author

brentvatne commented Jun 26, 2018

willFocus to show a loading spinner and didFocus to show the calendar? Will that not be the same as InteractionManager.runAfterInteractions example?

yeah it would be very similar.

the issue is basically the following - if you push a screen and it is computationally expensive enough to render that it is going to block the main thread, you have three options (as far as i know):

  1. break it up into chunks and render incrementally so it doesn't block the main thread enough to interrupt the transition
  2. render either some placeholder content or just partial / minimal content, then render full content once transition is complete (can use focus events or InteractionManager or whatever for this)
  3. render the screen first (block the main thread briefly before you push the screen) then animate it in

react-navigation doesn't do any of those three for you out of the box. react-native-navigation, as of v2 it would seem, does number 3 for you out of the box.

in the case of this calendar, perhaps the implementation could be changed so that it uses the first strategy. i'm not familiar with the implementation but based on what i saw above it looks like it renders everything at once

@Diwei-Chen
Copy link

Diwei-Chen commented Mar 1, 2019

Hi @brentvatne , as stated on https://facebook.github.io/react-native/docs/performance#slow-navigator-transitions:

One solution to this is to allow for JavaScript-based animations to be offloaded to the main thread.

Wondering what is the status of this?

If animation is hanged over to UI thread, then regardless a screen is computationally expensive or not, navigation animation should never be blocked, right? FYI, @adamgins

@brentvatne
Copy link
Member Author

the status is that it happens automatically in react-navigation. if there is a slowdown in the animation then it is because there is too much work being done on the ui thread

@slorber
Copy link
Member

slorber commented Mar 1, 2019

Hi,

I'd be interested to work on this kind of feature but wonder if it's not more flexible to support an additional community package to help doing that, as it could be handy outside of react-navigation too. But in the same way some tab navs are external, it could be possible to make an adaptation layer and offer a simple interface in react-navigation.

Any idea what kind of api we should build?

I'm thinking we could have some kind of placeholderScreen attribute to screen config

createStackNavigator({

  Home: { screen: HomeScreen},
  Calendar: {
    screen: CalendarScreen,
    screenPlaceholder: {
      screen: ScreenWithSpinner,
      animation: { ...},
    } 
  }
}}

I've already some working code in some apps that use runAfterInteraction and a quite generic Overlay component with animated transitions from the placeholder screen to the heavy screen

@brentvatne
Copy link
Member Author

@slorber - i'd be open to adding an experimental option for that so we can try it out, maybe something like this:

createStackNavigator({
  Home: { screen: HomeScreen},
  Calendar: {
    screen: CalendarScreen,
    loadingPlaceholderExperimental: ScreenWithSpinner,
  }
}}

@hrahimi270
Copy link

@slorber
Can we use loadingPlaceholderExperimental in v3 now?

@slorber
Copy link
Member

slorber commented Apr 5, 2019

Hi, no sorry didn't have time to work on this unfortunately

@brentvatne
Copy link
Member Author

would be neat for someone to have a go at this

@Inovassist-dev
Copy link

Hey Guys, any update in a plugin native workaround?

@ammoradi
Copy link

ammoradi commented Sep 17, 2019

any news?
at the moment we should use one of @brentvatne 's solutions or delay the full render of heavy screens using timeout.
If we don't. It takes seconds to get to the destination screen.
it is ugly, bad and ... !

@nandorojo
Copy link

3. render the screen first (block the main thread briefly before you push the screen) then animate it in

@brentvatne This feels like the best solution to me. Seems to follow the suspense-ful direction react is going towards.

@nandorojo
Copy link

nandorojo commented Mar 31, 2020

This has been my solution so far. Wait until interactions are done, then fade in the screen.

I can put it into an npm package if that would be useful.

1. use-after-interactions.ts

A hook that updates the interaction state and runs a transition when it's done.

import { useState, useEffect, useRef } from 'react'
import { InteractionManager } from 'react-native'
import { TransitioningView } from 'react-native-reanimated'

export const useAfterInteractions = () => {
  const [interactionsComplete, setInteractionsComplete] = useState(false)

  const subscriptionRef = useRef(null)

  const transitionRef = useRef<TransitioningView>(null)

  useEffect(() => {
    subscriptionRef.current = InteractionManager.runAfterInteractions(() => {
      transitionRef.current?.animateNextTransition()
      setInteractionsComplete(true)
      subscriptionRef.current = null
    })
    return () => {
      subscriptionRef.current?.cancel()
    }
  }, [])

  return {
    interactionsComplete,
    transitionRef,
  }
}

2 with-interactions-managed.tsx

Higher-order component that renders your screen after transitions are done. If they aren't done, it renders an optional placeholder.

import React, { ComponentType } from 'react'
import { Transition, Transitioning } from 'react-native-reanimated'
import { useAfterInteractions } from '../hooks/use-after-interactions'

export function withInteractionsManaged<Props>(
  Component: ComponentType<Props>,
  Placeholder: ComponentType | null = null
) {
  return (props: Props) => {
    const { transitionRef, interactionsComplete } = useAfterInteractions()
    return (
      <Transitioning.View
        transition={
          <Transition.Together>
            <Transition.Change interpolation="easeInOut" />
            <Transition.In type="fade" />
          </Transition.Together>
        }
        style={{ flex: 1 }}
        ref={transitionRef}
      >
        {interactionsComplete ? (
          <Component {...props} />
        ) : (
          Placeholder && <Placeholder />
        )}
      </Transitioning.View>
    )
  }
}

And then when exporting your screen, wrap it with this HOC:

SomeScreen.tsx

import { withInteractionsManaged } from './with-interactions-managed'
...


export default withInteractionsManaged(SomeScreen)

// or, with a placeholder:
export default withInteractionsManaged(SomeScreen, Placeholder)

@alexborton
Copy link

@nandorojo this is a great solution and shows a marked improvement for my heavier screens.

I am however struggling to get the Title to show as it did.. The setup of the screen itself looks like this;

TitleScreen.navigationOptions = ({ navigation }) => ({
  title: navigation.getParam('name'),
  headerRight: null,
})

the name is passes into the screen on navigation. I also noted that the headerRight is also ignored - so it seems that none of that static function is respected

@nandorojo
Copy link

The solution would be to hoist non react statics. I’ll edit my answer shortly.

https://github.com/mridgway/hoist-non-react-statics/blob/master/README.md

@nandorojo
Copy link

nandorojo commented Apr 6, 2020

I updated withInteractionsManaged in the answer above to look like this:

import hoistNonReactStatics from 'hoist-non-react-statics'
...
  const Wrapped = (props: Props) => {
    const { transitionRef, interactionsComplete } = useAfterInteractions()
    return (
      <Transitioning.View
        transition={
          <Transition.Together>
            <Transition.Change interpolation="easeInOut" />
            <Transition.In type="fade" />
          </Transition.Together>
        }
        style={{ flex: 1 }}
        ref={transitionRef}
      >
        {interactionsComplete ? (
          <Component {...props} />
        ) : (
          Placeholder && <Placeholder />
        )}
      </Transitioning.View>
    )
  }
  // forward navigationOptions, and other statics
  hoistNonReactStatics(Wrapped, Component)
  return Wrapped

@nandorojo
Copy link

I put the above solution into an npm package called react-navigation-heavy-screen. Figure it might be useful until there is a more official solution.

import { optimizeHeavyScreen } from 'react-navigation-heavy-screen'

const Screen = () => ...

export default optimizeHeavyScreen(Screen, OptionalPlaceHolderScreen)

@PedroBern
Copy link

My workaround is listening to the focus and optionally to transitionEnd events, in my components, and while it's not ready I render a placeholder. The screen transition will be smooth.

// useIsReady.ts

import { useNavigation } from '@react-navigation/native'
import React from 'react'

const useIsReady = (stack: boolean = true) => {
  const navigation = useNavigation()
  const [isReady, setIsReady] = React.useState(false)
  React.useEffect(() => {
    const unsubscribeFocus = navigation.addListener('focus', () => {
      if (!isReady) setIsReady(true)
    })

    const unsubscribeTransitionEnd = stack
      ? // @ts-ignore
        navigation.addListener('transitionEnd', () => {
          if (!isReady) setIsReady(true)
        })
      : undefined

    return () => {
      unsubscribeFocus()
      unsubscribeTransitionEnd && unsubscribeTransitionEnd()
    }
  }, [])
  return isReady
}

export default useIsReady

Some component...

const HeavyComponentThatMakeNavigationLooksCrap = () => {
  const isReady = useIsReady()
  return isReady ? ... : <Placeholder />
}

If you have multiple heavy components in the screen, it is better to use it directly in the screen:

const ScreenWithMultipleHeavyComponents = () => {
  const isReady = useIsReady()
  return isReady ? ... : <ScreenPlaceholder />
  // or
  return (
    <>
       ...
       {isReady ? ... : <ComponentsPlaceholder />}
    </>
  )
}

Just a workaround...

It's not a solution because if your component is really heavy, it is still blocking the js thread, which is also true for the react-navigation-heavy-screen solution above. Although the page transition will be smooth, again, the same as the above solution.

@alexandrius
Copy link

alexandrius commented Mar 10, 2021

Implemented good workaround.
Basically the ChunkView will not render whole screen all together. It will render chunk by chunk

import React, { useEffect, useState, useRef } from 'react';
import { InteractionManager } from 'react-native';

const BATCH_SIZE = 4;

export default function ChunkView({ children }) {
   const [batchIndex, setBatchIndexRaw] = useState(1);
   const focusedRef = useRef(true);
   const batchIndexRef = useRef(1);
   const reachedEndRef = useRef(false);

   const childrenChunk = reachedEndRef.current
      ? children
      : children.slice(0, BATCH_SIZE * batchIndex);

   const setBatchIndex = (index) => {
      batchIndexRef.current = index;
      setBatchIndexRaw(index);
   };

   const loadNextBatch = (timeout = 800) => {
      InteractionManager?.runAfterInteractions(() => {
         setTimeout(() => {
            if (focusedRef.current) {
               const nextBatchIndex = batchIndexRef.current + 1;
               if (nextBatchIndex * BATCH_SIZE >= children.length) {
                  reachedEndRef.current = true;
               } else {
                  loadNextBatch();
               }
               setBatchIndex(nextBatchIndex);
            }
         }, timeout);
      });
      return () => (focusedRef.current = true);
   };

   useEffect(() => {
      loadNextBatch(1000);
   }, []);

   return <>{childrenChunk}</>;
}

@janicduplessis
Copy link

janicduplessis commented Mar 10, 2021

I'm using https://github.com/th3rdwave/react-native-incremental which used to be part of react-native but was removed. Screens using FlatList should not need anything special as it already does incremental rendering.

<>
  <MainContent />
  <IncrementalGroup name="detail_content">
    <Incremental>
      <ContentA />
    </Incremental>
    <Incremental>
      <ContentB />
    </Incremental>
  </IncrementalGroup>
</>

Incremental components inside a IncrementalGroup will be rendered one after the other inside InteractionManager callbacks. This assure JS is not blocked for long periods of time.

@liquidvisual
Copy link

Would it be worth breaking up the page into subcomponents and rendering with a SectionList?

@wahas-think
Copy link

This has been my solution so far. Wait until interactions are done, then fade in the screen.

I can put it into an npm package if that would be useful.

1. use-after-interactions.ts

A hook that updates the interaction state and runs a transition when it's done.

import { useState, useEffect, useRef } from 'react'
import { InteractionManager } from 'react-native'
import { TransitioningView } from 'react-native-reanimated'

export const useAfterInteractions = () => {
  const [interactionsComplete, setInteractionsComplete] = useState(false)

  const subscriptionRef = useRef(null)

  const transitionRef = useRef<TransitioningView>(null)

  useEffect(() => {
    subscriptionRef.current = InteractionManager.runAfterInteractions(() => {
      transitionRef.current?.animateNextTransition()
      setInteractionsComplete(true)
      subscriptionRef.current = null
    })
    return () => {
      subscriptionRef.current?.cancel()
    }
  }, [])

  return {
    interactionsComplete,
    transitionRef,
  }
}

2 with-interactions-managed.tsx

Higher-order component that renders your screen after transitions are done. If they aren't done, it renders an optional placeholder.

import React, { ComponentType } from 'react'
import { Transition, Transitioning } from 'react-native-reanimated'
import { useAfterInteractions } from '../hooks/use-after-interactions'

export function withInteractionsManaged<Props>(
  Component: ComponentType<Props>,
  Placeholder: ComponentType | null = null
) {
  return (props: Props) => {
    const { transitionRef, interactionsComplete } = useAfterInteractions()
    return (
      <Transitioning.View
        transition={
          <Transition.Together>
            <Transition.Change interpolation="easeInOut" />
            <Transition.In type="fade" />
          </Transition.Together>
        }
        style={{ flex: 1 }}
        ref={transitionRef}
      >
        {interactionsComplete ? (
          <Component {...props} />
        ) : (
          Placeholder && <Placeholder />
        )}
      </Transitioning.View>
    )
  }
}

And then when exporting your screen, wrap it with this HOC:

SomeScreen.tsx

import { withInteractionsManaged } from './with-interactions-managed'
...


export default withInteractionsManaged(SomeScreen)

// or, with a placeholder:
export default withInteractionsManaged(SomeScreen, Placeholder)

This is not working with react native 0.67.3, I guess its not supported on latest react native versions

@nandorojo
Copy link

Today, using native-stack should be enough.

@md-ajju
Copy link

md-ajju commented Aug 5, 2022

Today, using native-stack should be enough.

Still not working.

@alexandrius
Copy link

@md-ajju
Try this #51 (comment) .

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests