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

Need a way to set what scroll position is saved #1187

Open
brad426 opened this issue Feb 22, 2017 · 29 comments
Open

Need a way to set what scroll position is saved #1187

brad426 opened this issue Feb 22, 2017 · 29 comments
Labels
feature request needs RFC This feature request needs to go through the RFC process to gather more information

Comments

@brad426
Copy link

brad426 commented Feb 22, 2017

Similar to the way you can define a scrollBehavior function on a router instance, I need a way to define how the savedPosition value gets computed/populated. I need to override the default behavior which stores the window's scroll position, and instead store the scroll position of another element.

My application uses a structure where the document/window stays static, and content scrolls inside of a container. Example: http://codepen.io/brad426/pen/pezRge

@LinusBorg
Copy link
Member

LinusBorg commented Feb 22, 2017

Hi Brad.

just thinking out loud:

I think this won't be so easy to do since those two things - the scrollposition the router saves right now, and the scrollpostion of an arbitrary element - have not much in common, technically. One works with the popstate event and push() method of window.history, while the other requires to read and write to a certain DOM elements property.

Also, this functionality is not necessarily tied to router components, but could be useful to all sorts of components, which makes me think that it would be better to do this in a generic plugin/component ...

@posva
Copy link
Member

posva commented Feb 22, 2017

The scrollBehavior could be an object instead of a function but as @LinusBorg I'm wondering if keeping this behaviour in a component isn't more flexible and reusable

@brad426
Copy link
Author

brad426 commented Feb 22, 2017

Cool, thanks for your feedback.
I agree that this functionality would be useful to all sorts of components, but I think a lot of people might also find a "global" override useful.

Regarding my example app structure, I'd like to keep the router's functionality exactly as is, i.e. have it call saveScrollPosition whenever a popstate event is triggered, the only change would be to allow a custom handler for saveScrollPosition . Example:

const router = new VueRouter({
  mode: 'history',
  routes,
  saveScrollPosition() {
     let el = document.querySelector('.page-content')
     return { x: el.scrollLeft, y: el.scrollTop }
  }
})

@erguotou520
Copy link

@LinusBorg see my issue's program #1249 , is that possible to realize?

@blake-newman
Copy link
Member

IMO, we should set a scrollEl property in main router config. Thus allowing us to use a specific scroll container for both scrollBehavior and scroll position.

What's everyone's opinion on this?

@Dadibom
Copy link

Dadibom commented Aug 3, 2017

You don't need special functions for this, before/afterEach would be a much better place to put these kinds of things

@unamix
Copy link

unamix commented Sep 22, 2017

It would be nice if we could somehow define from which element we want to save the scroll position.
In the meantime i solved it this way:

<keep-alive>
  <router-view></router-view>
</keep-alive>
const router = new VueRouter({
  mode: 'history',
  routes
})

const scrollableElementId = 'content' // You should change this
const scrollPositions = Object.create(null)

router.beforeEach((to, from, next) => {
  let element = document.getElementById(scrollableElementId)
  if (element !== null) {
    scrollPositions[from.name] = element.scrollTop
  }

  next()
})

window.addEventListener('popstate', () => {
  let currentRouteName = router.history.current.name

  let element = document.getElementById(scrollableElementId)
  if (element !== null && currentRouteName in scrollPositions) {
    setTimeout(() => element.scrollTop = scrollPositions[currentRouteName], 50)
  }
})

@kgrosvenor
Copy link

I think this would be a pretty good and obvious feature to add :D

@FEA5T
Copy link

FEA5T commented Jan 30, 2018

@blake-newman I disagree, I think that each router-view (component) should define it's scrollEl with a local scrollEl property on the component, or a v-scrollEl directive in html.

In terms of encapsulation, each component could be from a different source, as an app could be composed of many separate components from different authors on the web. Each component know's which child element within needs to scroll. It would be inappropriate to let the app decide what element within all components should be scrollable (as you would have to go into each component and add the scroll class/id).

If anything, vue should implement an html directive ie: <div v-scrollEl></div> that vue automatically picks up to override scroll position tracking for that component only.

This way it:

  1. Is up to the component what child element scrolls
  2. Provides consistent syntax across components and teams for readability
  3. Does not pollute router config

@posva posva removed the 2.x label Jan 30, 2018
@FEA5T
Copy link

FEA5T commented Feb 15, 2018

@yyx990803 Does vue-router save scroll position of the <router-view> or window?

Also, it seems that vue-router should NOT implement this at all...scrolling a container that is not supported has unintended side-effects ex: safari scroll to top doesnt work, mobile pull to reload can get trapped, scrolling can get trapped by an outer element and require two taps to scroll, etc.

@chasebank
Copy link

chasebank commented Feb 21, 2018

Those are all unfortunate side-effects, but mobile browsers ignoring overflow: hidden on body sometimes forces us to use another container for scrolling, since the only way around that is setting a wrapper element to position: fixed

@FEA5T
Copy link

FEA5T commented Feb 22, 2018

On mobile browsers, the browser url bar/back buttons (think mobile safari/chrome) are hidden according to scroll position on the window/body. If you use overflow: hidden; on the window/body then you have just disabled the browsers ability to show/hide its controls as well as the many other things I mentioned. What I am saying, really, is that you should avoid disabling scrolling on the body and using position: fixed; to create your own viewport, and try to find another way/use routing or conditionals with transitions if you need to bring in overlays etc.

@francoism90
Copy link

Any update on this? I want to catch if the savedPosition has been set, and if possible read out the x/y and overrule this. Thanks!

@mjl
Copy link

mjl commented Sep 5, 2018

Just as a reference, after some head scratching, here is a pure vue solution (no jquery needed)

In the root component, give the scrolling element a ref property, and pass a scroll_last_position prop to the router-view, containing a callback that restores the last scroll position.

<div ref="scrolled">
    <keep-alive>
        <router-view :scroll_last_position="scroll_container"></router-view>
    </keep-alive>
</div>

Hook in a afterEach route guard so the root element knows when navigation happens. In that route guard, capture the current scroll position of the referenced element and store it for the from route, then restore the old position of the to route.

Also, provide a method for restoring a scroll position.

(This is coffeescript, sorry. I'm confident you can deal with it)

root_component =
    data: () ->
        scroll: 0
        scroll_positions: {}

    created: () ->
        this.$router.afterEach (to, from) =>
            # On each router change, note the current scroll position of the
            # container element for the old route; then change the scroll
            # position to the last remembered position of the new route, if
            # any.
            # The child element then can trigger scrolling to that position
            # by calling its "on_activate" prop.
            this.scroll_positions[from.name] = this.$refs.scrolled.scrollTop
            old_pos = this.scroll_positions[to.name]
            this.scroll = if old_pos then old_pos else 0

    methods:
        scroll_container: () ->
            this.$refs.scrolled.scrollTop = this.scroll

Finally, in the child component, restore the scroll position if wanted:

child_component =
    props:
        scroll_last_position:
            type: Function

    activated: () ->
        this.scroll_last_position()

@Skriptach
Copy link

Hello everyone!
What is a current status? Is it implemented? Or need use one of custom workaround proposed above?

@chrillefkr
Copy link

I save scroll states of a child component by saving $refs.scrollElement.scrollTop on beforeRouteLeave, and restore it at beforeRouteEnter. This requires the to be wrapped in (or to simply have someplace else to store the scroll data, like vuex), so that the scroll data is available for routeBeforeEnter. E.g:

`

Bunch of content

<script> export default { data: () => ({ lastScroll: 0, }), beforeRouteEnter (to, from, next) { next(vm => { vm.$refs.scroll.scrollTop = vm.lastScroll }) }, beforeRouteLeave (to, from, next) { this.lastScroll = this.$refs.scroll.scrollTop next() }, } </script>

`

I'm fairly certain this method could be made into a plugin of some sort. I hope this helped anyone!

@zeroinformatique
Copy link

zeroinformatique commented Feb 11, 2020

A very simple way to do it in TypeScript:

@Ref() readonly content!: Vue; // Points to your scrollable component

scrollPositions: { [index: string]: number }= {};

@Watch("$route")
routeChanged(newRoute: Route, oldRoute: Route) {
  const el = this.content.$el;
  this.scrollPositions[oldRoute.path] = el.scrollTop;
  this.$nextTick(() => (el.scrollTop = this.scrollPositions[newRoute.path]));
}

@icefee
Copy link

icefee commented Mar 25, 2020

contain this mixins in your router page.

export default {
beforeRouteLeave (to, from, next) {
const scroller = this.$refs.scroller;
if(scroller) {
if(!window.__scrollOffset) {
window.__scrollOffset = {};
}
window.__scrollOffset[this.$route.fullPath] = {
x: scroller.scrollLeft,
y: scroller.scrollTop
};
next()
}
},
methods: {
setScroll(scroller) {
if(window.__scrollOffset && window.__scrollOffset[this.$route.fullPath]) {
let { x, y } = window.__scrollOffset[this.$route.fullPath];
scroller.scrollLeft = x;
scroller.scrollTop = y;
}
}
},
}

when your page rendered, calling the function to set scroll position.

@posva posva added the needs RFC This feature request needs to go through the RFC process to gather more information label May 26, 2020
@719media
Copy link

719media commented Jun 4, 2020

This behavior would be incredibly useful to get scrolling working without requiring html history usage. And would render libraries such as https://github.com/jeneser/vue-scroll-behavior unnecessary (at least when using html history mode), since we could just use the vue-router behavior to do such.

As a side note, it would be nice if the window.scrollTo(position.x, position.y) line in scroll.js was overridable so that custom scroll libraries could be used instead of scrollTo.

@21stcn
Copy link

21stcn commented Aug 24, 2020

Extending @iBrazilian2 's solution, you can leverage the savedPosition parameter in scrollBehavior to only navigate to the saved position of your scroll element when navigating back/forward from history (popstate). This seems the most simple solution for default history behavior in vue with a scrollable element that isn't window:

// router.js

const scrollableElementId = 'id-of-your-scrollable-element'; // change id
const scrollPositions = Object.create(null);

const router = new VueRouter({
    mode: 'history',
    routes,
    scrollBehavior(to, from, savedPosition) {
        const element = document.getElementById(scrollableElementId);

        if (savedPosition && element !== null && to.name in scrollPositions) {
            console.log(
                '%c%s',
                'color:hotpink;',
                'scrollBehavior: navigating to history entry, scroll to saved position',
            );

            element.scrollTop = scrollPositions[to.name];

        } else {
            console.log('%c%s', 'color:hotpink;', 'navigating to new history entry, scroll to top');
            element.scrollTop = 0;
        }

    },
});

router.beforeEach((to, from, next) => {
    const element = document.getElementById(scrollableElementId);
    if (element !== null) {
        scrollPositions[from.name] = element.scrollTop;
    }

    next();
});

@c5n8
Copy link

c5n8 commented Nov 8, 2020

Here's my solution:

<script>
const savedPosition = {
  x: 0,
  y: 0,
}

export default {
  mounted () {
    this.$refs.scrollable.scrollTo({
      left: savedPosition.x,
      top: savedPosition.y,
    })
  },

  beforeDestroy () {
    savedPosition.x = this.$refs.scrollable.scrollLeft
    savedPosition.y = this.$refs.scrollable.scrollTop
  },
}
</script>

@gabrielsze
Copy link

the original suggestion would be a good use case to add, am wondering if this is still being considered?

@jaswanthmagure
Copy link

jaswanthmagure commented Feb 26, 2021

In my case, Nothing worked for me
but this below worked

`<transition @before-enter="scrollTop" mode="out-in" appear>

methods: {
scrollTop(){
document.getElementById('app').scrollIntoView();
},
}`

@MuTsunTsai
Copy link

For those who want the scrollBehavior to apply to another element instead of window, there's a way to hack the default behavior. VueRouter use window.pageXOffset and window.pageYOffset to get the position during saving, and calls window.scrollTo to apply the saved position. Therefore it sufficies to override these three:

Object.defineProperty(window, 'pageXOffset', {
	get() { return document.querySelector('main')?.scrollLeft ?? 0; },
});
Object.defineProperty(window, 'pageYOffset', {
	get() { return document.querySelector('main')?.scrollTop ?? 0; },
});
Object.defineProperty(window, 'scrollTo', {
	value: (option: { top: number, left: number }) => {
		let els = document.querySelectorAll('main');
		let el = els[els.length - 1];
		el?.scrollTo(option.left, option.top);
	},
});

In my case, my scroll container is the <main> element. Notice that in my scrollTo function, I query the last matching element, in order to make it compatible with transitions (in which multiple <main> elements will co-exist).

@pavlexander
Copy link

pavlexander commented Nov 15, 2021

The realization I have made that if you use the container to show the data - the scrollBehavior function is no longer something you could rely on.. @MuTsunTsai made a good explanation how you could do some overrides to make it work for you. In my case, however, I find it much easier to just override the scroll position per specific page, since I don't have that many pages anyways.

there is one page that shows the infinite list of items and is using keep-alive directive to cache the contents.

onMounted is only called once the component is initialized the first time, meaning that no data is loaded and we can simply scroll to top. In other cases when the onActivated and onDeactivated are invoked we know that transition between cached page and other components are happening. The key here is to first detect if the page is cached or not.

On the non-cached pages you simply use:

onMounted(() => {
  document.getElementById("content").scrollTop = 0;
});

One thing I would improve still is adding a composable function instead of repeating the same logic over and over. I haven't gotten that far yet :)

EDIT: nevermind here are 2 functions.

useCachedContent.js

import { onMounted, onActivated, onDeactivated, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";

export function useCachedContent(routeName) {
  const router = useRouter();

  let scrollPosition = 0;
  let isShowingCache = true;

  onMounted(() => {
    isShowingCache = false;
  });

  onActivated(() => {
    if (isShowingCache) {
      document.getElementById("content").scrollTop = scrollPosition;
    } else {
      document.getElementById("content").scrollTop = 0;
    }
  });

  router.beforeEach((to, from, next) => {
    if (from.name === routeName) {
      scrollPosition = document.getElementById("content").scrollTop;
    }

    next();
  });

  onDeactivated(() => {
    isShowingCache = true;
    //scrollPosition = document.getElementById("content").scrollTop; // will not always work unfortunatelly
  });
}

and useContent.js

import { onMounted } from "vue";

export function useContent() {
  onMounted(() => {
    document.getElementById("content").scrollTop = 0;
  });
}

in script tag

import { useCachedContent } from "../use/useCachedContent";

in setup()

useCachedContent('route name here');

put it at the end, just in case. Because order matters. If you have other onMounted, onActivated, onDeactivated methods you probably want them to run first.

edit 2: unfortunately it looks like onDeactivated function is not guaranteed to be always executed before the content from the old page disappears. Therefore you will see incorrect result in SOME cases (scroll position 0). I have updated the function to use the router's beforeEach hook instead. Now we have to supply input parameter that is equal to route name.. A little bit more work but still works as intended.

@kryvonos-v
Copy link

kryvonos-v commented Sep 23, 2022

Here is an alternative implementation (inspired by @unamix):

const SCROLL_CONTAINER_ID = 'content';
const scrollPositions = {};

function scrollBehavior(to, from, savedPosition) {
  if (to.fullPath === from.fullPath) {
    return;
  }

  const isNavigationForward = (savedPosition === null);
  const contentEl = document.getElementById(SCROLL_CONTAINER_ID) as HTMLElement;

  console.assert(contentEl !== null, 'Scroll container not found');

  if (isNavigationForward) {
    scrollPositions[from.fullPath] = {
      top: contentEl.scrollTop,
      left: contentEl.scrollLeft
    };
    contentEl.scroll({
      top: 0,
      left: 0
    });
  } else {
    const savedPosition = scrollPositions[to.fullPath];
    if (savedPosition) {
      contentEl.scroll(savedPosition);
    }
    delete scrollPositions[to.fullPath];
  }
};

This implementation relies on a fact that savedPosition is null when user navigates forward and { x: number; y: number; } otherwise. That's why we don't need to listen to popstate event and define our custom beforeEach navigation guard.

@toniengelhardt
Copy link

toniengelhardt commented Feb 19, 2023

Expanding on @unamix and @kryvonos-v, here's what works for me in Nuxt 3, including keep-alive lists, etc.

In the keep-alive case it is important to set the scroll to zero when navigating forward, otherwise you get unexpected behavior. To achieve this we have to split the logic into two.

1

Store the scroll position before leaving the route. This can be done easily in a plugin:

plugins/router.ts

export default defineNuxtPlugin(() => {
  const scrollElement = '#scroll-element'
  const scrollPositions = useState('savedScrollPositions', (): Record<string, ScrollPosition> => ({}))
  const router = useRouter()

  router.beforeEach((_to, from, next) => {
    const el = document.querySelector(scrollElement)
    if (el) {
      scrollPositions.value[from.fullPath] = {
        top: el.scrollTop,
        left: el.scrollLeft,
      }
    }
    next()
  })
})

2

Restoring the position when navigating forwards or backward via router options:

app/router.options.ts

import type { RouterOptions } from '@nuxt/schema'

export default <RouterOptions> {
  scrollBehavior(to, _from, savedPosition) {
    const scrollElement = document.querySelector('#scroll-element')
    if (scrollElement) {
      let scrollPosition: ScrollPosition
      if (savedPosition) {
        const savedScrollPositions = useState('savedScrollPositions', (): Record<string, ScrollPosition> => ({}))
        scrollPosition = savedScrollPositions.value[to.fullPath]
      }
      if (!scrollPosition) {
        scrollPosition = { top: 0, left: 0 }
      }
      scrollElement.scrollTop = scrollPosition!.top
    }
  },
}

To keep dynamic lists alive, we also need to add keepalive to the <NuxtPage /> component:

app.vue

<template>
  <div>
    <NuxtLayout>
      <NuxtPage :keepalive="{}" />
    </NuxtLayout>
  </div>
</template>

@antfu
Copy link
Member

antfu commented Jun 8, 2023

For Vue Router 4 (Vue 3), together with @posva we made a userland solution to support custom and multiple scrolling targets: https://github.com/antfu/vue-router-better-scroller

Feel free to give a try and create issues in that repo if you have any feedback!

@vis97c
Copy link

vis97c commented Dec 31, 2024

For Vue Router 4 (Vue 3), together with @posva we made a userland solution to support custom and multiple scrolling targets: https://github.com/antfu/vue-router-better-scroller

Feel free to give a try and create issues in that repo if you have any feedback!

This right here should be the official implementation, As a suggestion I would probably expose the types. Here is how I'm using it in my nuxt 3 project:

// plugins/scrollBehavior.client.ts
import { createRouterScroller } from "vue-router-better-scroller";

interface SavedPosition {
  top?: number;
  left?: number;
  behavior?: ScrollOptions["behavior"];
}

function scrollHandler({ savedPosition }: { savedPosition?: SavedPosition }): SavedPosition {
  return savedPosition || { top: 0, left: 0, behavior: "smooth" };
}

export default defineNuxtPlugin(({ vueApp }) => {
  vueApp.use(
    createRouterScroller({
      selectors: {
        window: scrollHandler,
        body: scrollHandler,
        ".scrollable": scrollHandler,
      },
    })
  );
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature request needs RFC This feature request needs to go through the RFC process to gather more information
Projects
None yet
Development

No branches or pull requests