vue-router icon indicating copy to clipboard operation
vue-router copied to clipboard

Need a way to set what scroll position is saved

Open brad426 opened this issue 7 years ago • 29 comments

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

brad426 avatar Feb 22 '17 11:02 brad426

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 ...

LinusBorg avatar Feb 22 '17 12:02 LinusBorg

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

posva avatar Feb 22 '17 13:02 posva

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 }
  }
})

brad426 avatar Feb 22 '17 13:02 brad426

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

erguotou520 avatar Mar 15 '17 05:03 erguotou520

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?

blake-newman avatar May 25 '17 17:05 blake-newman

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

Dadibom avatar Aug 03 '17 15:08 Dadibom

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)
  }
})

unamix avatar Sep 22 '17 10:09 unamix

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

kgrosvenor avatar Nov 08 '17 19:11 kgrosvenor

@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

FEA5T avatar Jan 30 '18 21:01 FEA5T

@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.

FEA5T avatar Feb 15 '18 21:02 FEA5T

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

chasebank avatar Feb 21 '18 21:02 chasebank

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.

FEA5T avatar Feb 22 '18 02:02 FEA5T

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!

francoism90 avatar Aug 23 '18 13:08 francoism90

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()

mjl avatar Sep 05 '18 11:09 mjl

Just wanted to "encapsulate" @mjl code, considering that this should be implemented in the near future keeping everything together should be easier further down the line.

For any routes you want to remember the position, just add the route name to the remember array.

<template>
  <div class="container-with-scrollbar" ref="scrolled">
    <router-view></router-view>
  </div>
<template>

<script>
export default {
  data(){
    return {
      scroll_positions: {},
      remember: []
    }
  },
  created(){
    this.$router.afterEach( (to, from) => {
      this.scroll_positions[from.name] = this.$refs.scrolled.scrollTop;

      let scroll = 0;
      if(this.scroll_positions.hasOwnProperty(to.name) && this.remember.includes(to.name)){
        scroll = this.scroll_positions[to.name];
      }
      this.$nextTick(()=>{
        this.$refs.scrolled.scrollTop = scroll;
      });
    });
  }
}
</script>

att1sb avatar Jun 10 '19 12:06 att1sb

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

Skriptach avatar Dec 07 '19 00:12 Skriptach

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

`

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

chrillefkr avatar Dec 31 '19 18:12 chrillefkr

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]));
}

zeroinformatique avatar Feb 11 '20 02:02 zeroinformatique

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.

icefee avatar Mar 25 '20 02:03 icefee

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.

719media avatar Jun 04 '20 20:06 719media

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();
});

21stcn avatar Aug 24 '20 07:08 21stcn

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>

c5n8 avatar Nov 08 '20 02:11 c5n8

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

gabrielsze avatar Dec 14 '20 15:12 gabrielsze

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(); }, }`

jaswanthmagure avatar Feb 26 '21 13:02 jaswanthmagure

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).

MuTsunTsai avatar Aug 06 '21 02:08 MuTsunTsai

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.

pavlexander avatar Nov 15 '21 11:11 pavlexander

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.

kryvonos-v avatar Sep 23 '22 09:09 kryvonos-v

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>

toniengelhardt avatar Feb 19 '23 15:02 toniengelhardt

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!

antfu avatar Jun 08 '23 20:06 antfu