QwikDev / qwik-evolution

Home for Qwik proposals and RFCs
15 stars 0 forks source link

[✨] View Transition Hook #55

Closed GrandSchtroumpf closed 1 month ago

GrandSchtroumpf commented 1 year ago

Is your feature request related to a problem?

I'm trying a animate a list of item on page navigation. For that I'm animating with Javascript, but there is no way to hook into the transition from the link.

const animate = $(() => {
  const items = document.querySelectorAll('.item');
  for (const item of items) {
    const transitionName = item.style.getProperty('view-transition-name');
    // Animation is not working because I need to be inside the transition
    document.documentElement.animate({
      transform: [....],
    }, {
      pseudoElement: `::view-transition-old(${transitionName})`,
      duration: 200
    })
  }
})
return <Link onClick$={animate} href="...">Link</Link>

Describe the solution you'd like

Ideally the Link element & useNavigation would expose a callback for that :

const animate = $((transition, element) => {
  ....
})
return <Link onViewTransition$={animate} href="...">Link</Link>

Describe alternatives you've considered

We cannot set the animation inside style because this is a pseudo element of html. As we cannot update the pseudo element with Javascript and we cannot use css variable like that ::view-transition-old(var(--name)). We cannot set a <style> on each element because it'll be removed before the view transition is ready.

The only solution is to add <style> inside head with all the ::view-transition-old() pseudo class and remove them:

const viewTransitionName = "...";
useVisibleTask$(() => {
  const style = document.createElement('stlye');
  style.textContent = `::view-transition-old(${viewTransitionName}) { animation: ....; }`
  const child = document.head.appenChild(style);
  return () => setTimeout(() => document.head.removeChild(child), 1000); // wait for transition to be done
});
return <Link href="">...</Link>

Note: I'm not using ::view-transition-old(*) because I want to target specific elements (not all) and I want to add delay.

Additional context

No response

mhevery commented 1 year ago

Related https://github.com/BuilderIO/qwik/issues/3664 and https://github.com/BuilderIO/qwik/issues/2078

wmertens commented 8 months ago

I'm going to point the other two issues here since they all want more or less the same thing.

The View Transitions API isn't supported by WebKit yet but according to the tracking issue they are ok with it.

Manu made https://pics-qwik.pages.dev/ last year using the ::view-transition CSS API. It uses this CSS which assigns the "picture" transition to the images.

So, do we just recommend to use this API or do we implement async callbacks in navigation to allow doing things manually?

GrandSchtroumpf commented 8 months ago

I think the discussion should go beyond navigation. The ViewTransition API can be used for any kind of state changes that involves a mutation of the DOM, not only navigation. Since rendering is async this is very difficult to run the view transition callback at the correct time, especially when animating with WAPI. An idea would be to have something like track that would add run view transition when the state changes :


useViewTransition(async ({ track, transition }) => {
  if (!transition) return track(state);
  await transition.ready;
  document.documentElement.animate(...);
})
wmertens commented 8 months ago

@GrandSchtroumpf so for animations you need to know which old DOM elements will be transformed to which new elements right? And with the ViewTransition API that is done via CSS.

Suppose we decide to only support the ViewTransition API, what is still missing in Qwik?

One thing we could do is put QRL attribures on DOM elements that are awaited before/after performing some DOM manipulation. Using sync$ they would even be instantly available. However, I'm not sure what we would need.

GrandSchtroumpf commented 8 months ago

Since this issue gathers all View Transition problems, let's split it into two part

When does rendering happens

The ViewTransition API expect a callback that would work like that :

  1. startViewTransition
  2. Take a screenshot and create a ::view-transition pseudo element on the documentElement
  3. Run callback that returns a promise
  4. When promise resolve: resolve the transition.ready promise & start transition in the next frame

This is an imperative API, while Qwik is reactive, so we don't know when DOM is updated

Here is a an example that illustrate the problem

const hidden = useSignal(true);
useVisibleTask$(({ track }) => {
  track(() => hidden.value);
  // Case 1: flaky since the DOM might not have been updated at this point
  document.startViewTransition(() => waitForNextFrame());
   // Case 2: slow because it waits for 200ms to start the transition
  document.startViewTransition(() => waitFor200ms());
})

In React they suggest to use flushSync inside the callback to force rerendering. It's a ok-ish workaround since the main thread might be already busy, which might create junky transitions.

Solution 1 One solution Qwik could integrate is a onNextRender function that would resolve when DOM has been rerendered.

const hidden = useSignal(true);
useVisibleTask$(({ track }) => {
  track(() => hidden.value);
  document.startViewTransition(() => onNextRender());
})

Note: Here we might not want to startViewTransition when state initializes.

Solution 2 Create a hook into qwik-city that would do that :

useViewTransition(({ track }) => track(() => hidden.value));

The hook would:

  1. runs once to know on what state changes to run
  2. start view transition when the state changes
  3. wait for next rendering to resolve the startViewTransition callback

Note: This solution might be better designed to allow dev to select in which case run or not the transition.

Hook into the transition flow

The API has two faces: CSS & JS.

CSS :

JS:

Example in my initial comment

Solution 1: Provide a way to hook into a transition :

const location = useLocation();
useViewtransition(({ track, before, ready, finished }) => {
  track(() => location.url);
  before(async () => {
    // Do something before `startViewTransition` is called
  });
  ready(async () => {
    // Hooked into the `transition.ready` promise callback
    // run WAPI animation
  });
  finished(async () => {
    // Hooked into the `transition.finished` promise callback
    // cleanup style or classes needed for the transition
  })
});
useViewTransition(({ track }) => track(() => hidden.value));

The benefit of this API is that we can listen on page transition or local state transition with different behaviors. The issue is that it's not easy to have different behavior depending on the Link

Solution 2: Maybe we can plug useViewTransition with <Link/> :

const transition = useViewtransition(({ before, ready, finished }) => {
  before(async () => {
    // Do something before `startViewTransition` is called
  });
  ready(async () => {
    // Hooked into the `transition.ready` promise callback
    // run WAPI animation
  });
  finished(async () => {
    // Hooked into the `transition.finished` promise callback
    // cleanup style or classes needed for the transition
  })
});
return <Link transition={transition} />

This could be used in any UI library which wants to leverage the View Transition API power inside Qwik :

const TabGroup = component$(({ transition }) => {
  const selected = useState(null);
  const select = $((e, el) => {
    console.log(transition.id);
    transition.start(() => selected.value = el.ariaControls)
  });
  return (
    <button role="tab" onClick$={select} aria-controls="tab-panel-1">...</button>
    <div role="tabpanel" id="tab-panel-1">...</div
  )
})

Conclusion

We can have something like that, and use it inside the <Link /> component

const useViewtransition = (params) => {
  const initialized = useSignal(false);
  const id = useId();
  const start = $(async (cb = (() => {})) => {
    await params.before();
    const viewTransition = await new Promise((res, rej) => {
      const transition = document.startViewTransition(async () => {
        await cb();
        await nextRendering();
        res(transition);
      })
    });
    viewTransition.read.then(params.ready);
    viewTransition.finished.then(params.finished)
  });

  useTask$(({ track }) => {
    if (!params.track) return;
    track(params.track);
    if (!initialized.value) return initialized.value = true;
    if (!isServer) start();
  })
  return { id, start }
}
wmertens commented 8 months ago

Note that we could have a QRL prop on DOM nodes that gets awaited before it gets changed by Qwik. Would that be enough?

GrandSchtroumpf commented 8 months ago

I'm not sure, like a onViewTransitionstart$, onViewTransitionReady$, and onViewTransitionFinished$ ?

The startViewTransition is based on an action that will change the dom (page navigation, tab, popover, form,...). In a reactive framework like qwik the DOM usually changes with state update. So for me, it makes more sense to me to build around the state changes process and not a DOM element.

wmertens commented 8 months ago

No I meant like onBeforeDomNodeWillChange

gioboa commented 1 month ago

We moved this issue to qwik-evolution repo to create a RFC discussion for this. Here is our Qwik RFC process thanks.