├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── dist ├── vue-scroll-list.common.js ├── vue-scroll-list.esm.js └── vue-scroll-list.js ├── example ├── App.vue ├── componentA.vue ├── componentB.vue ├── index.html └── index.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src └── index.js ├── webpack.config.base.js ├── webpack.config.demo.js └── webpack.config.dev.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "es2015", 5 | { 6 | "modules": false 7 | } 8 | ] 9 | ], 10 | "plugins": [ 11 | "external-helpers" 12 | ] 13 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | npm-debug* 4 | .idea/ 5 | .vscode/ 6 | onlineDemo/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 KyLeo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | NPM version 3 | 4 | 5 | # vue-scroll-list 6 | > A vue component support infinite scroll list.Different item height is also supported. 7 | 8 | note: Vue version >= 2.3 is needed. 9 | 10 | ## Install 11 | 12 | ```bash 13 | $ npm install vue-scroll-list --save-dev 14 | ``` 15 | 16 | ## Demos 17 | 18 | [infinite data](http://freeui.org/vue-scroll-list/) 19 | 20 | ## Usage 21 | 22 | ```html 23 | 47 | 95 | 120 | ``` 121 | You can define the height of container(such as the `ul` tag above) by the css height. 122 | note: You can run this demo by `npm run dev`. 123 | 124 | ## Props and Events 125 | 126 | Available `Prop` : 127 | 128 | *Prop* | *Type* | *Required* | *Description* | 129 | :--- | :--- | :--- | :--- | 130 | | heights | Array | * | An array contains all height of your item.If you want to use `data-height`,please ignore this option. | 131 | | remain | Number | * | The number of item that show in view port.(default `10`) | 132 | | keep | Boolean | * | Work with `keep-alive` component,keep scroll position after activated.(default `false`) | 133 | | enabled | Boolean | * | If you want to render all data directly,please set 'false' for this option.But `toTop`、`toBottom` and `scrolling` event is still available.(default `true`) | 134 | | debounce | Number | * | Milliseconds of using debounce function to ensure scroll event doesn't fire so often.(disabled by default) | 135 | | step | Number | * | Pixel of using throttle theory to decrease the frequency of scroll event.(disabled by default) | 136 | 137 | Available `Event` : 138 | 139 | *Event* | *Description* | 140 | :--- | :--- | 141 | | toTop | An event emit by this library when this list is scrolled on top. | 142 | | toBottom | An event emit by this library when this list is scrolled on bottom. | 143 | | scrolling | An event emit by this library when this list is scrolling. | 144 | 145 | ## About heights prop 146 | `heights` property is an array contains all height of your item,but you can tell us the height of each item by setting the `data-height` property. 147 | ```html 148 |
151 |
152 | ``` 153 | Sometimes you may need to change the height of each item or filter your item.This may cause some blank problems.So you'd better call `update` function to tell us. 154 | ```html 155 | 164 |
169 | index:{{item.index}} / height:{{item.itemHeight}} 170 |
171 |
172 | ``` 173 | ```js 174 | this.$refs.vueScrollList && this.$refs.vueScrollList.update(); 175 | ``` 176 | ## License 177 | 178 | [MIT License](https://github.com/KyLeoHC/vue-scroll-list/blob/master/LICENSE) 179 | -------------------------------------------------------------------------------- /dist/vue-scroll-list.common.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _debounce = function _debounce(fn, wait) { 4 | var timeoutId = null; 5 | return function () { 6 | var _this = this, 7 | _arguments = arguments; 8 | 9 | var laterFn = function laterFn() { 10 | fn.apply(_this, _arguments); 11 | }; 12 | clearTimeout(timeoutId); 13 | timeoutId = setTimeout(laterFn, wait); 14 | }; 15 | }; 16 | 17 | var component = { 18 | props: { 19 | heights: { 20 | type: Array 21 | }, 22 | remain: { 23 | type: Number, 24 | default: 10 25 | }, 26 | enabled: { 27 | type: Boolean, 28 | default: true 29 | }, 30 | keep: { 31 | type: Boolean, 32 | default: false 33 | }, 34 | debounce: { 35 | type: Number 36 | }, 37 | step: { // throttle 38 | type: Number 39 | } 40 | }, 41 | methods: { 42 | handleScroll: function handleScroll(event) { 43 | var scrollTop = this.$el.scrollTop; 44 | if (!this.ignoreStep && this.step && Math.abs(scrollTop - this.scrollTop) < this.step) return; 45 | this.ignoreStep = false; 46 | this.scrollTop = scrollTop; 47 | this.$emit('scrolling', event); 48 | this.updateZone(scrollTop); 49 | }, 50 | updateHeightList: function updateHeightList() { 51 | if (this.heights) { 52 | this.heightList = this.heights; 53 | } else { 54 | var list = this.$slots.default || []; 55 | if (list.length !== this.heightList.length) { 56 | this.heightList = list.map(function (vnode) { 57 | return parseInt(vnode.data.attrs['data-height']); 58 | }); 59 | } 60 | } 61 | }, 62 | updateZoneNormally: function updateZoneNormally(offset) { 63 | // handle the scroll event normally 64 | var scrollHeight = this.$el.scrollHeight; 65 | var clientHeight = this.$el.clientHeight; 66 | if (offset === 0) { 67 | this.$emit('toTop'); 68 | } else if (offset + clientHeight + 5 >= scrollHeight) { 69 | this.$emit('toBottom'); 70 | } 71 | }, 72 | findOvers: function findOvers(offset) { 73 | // compute overs by comparing offset with the height of each item 74 | // @todo: need to optimize this searching efficiency 75 | var heightList = this.heightList; 76 | var overs = 0; 77 | var height = heightList[0]; 78 | var topReserve = Math.floor(this.reserve / 2); 79 | for (var length = heightList.length; overs < length; overs++) { 80 | if (offset >= height) { 81 | height += heightList[overs + 1]; 82 | } else { 83 | break; 84 | } 85 | } 86 | return overs > topReserve - 1 ? overs - topReserve : 0; 87 | }, 88 | updateZone: function updateZone(offset) { 89 | if (this.enabled) { 90 | this.updateHeightList(); 91 | var overs = this.findOvers(offset); 92 | 93 | // scroll to top 94 | if (!offset && this.total) { 95 | this.$emit('toTop'); 96 | } 97 | 98 | var start = overs || 0; 99 | var end = start + this.keeps; 100 | var totalHeight = this.heightList.reduce(function (a, b) { 101 | return a + b; 102 | }); 103 | 104 | // scroll to bottom 105 | if (offset && offset + this.$el.clientHeight >= totalHeight) { 106 | start = this.total - this.keeps; 107 | end = this.total - 1; 108 | this.$emit('toBottom'); 109 | } 110 | 111 | if (this.start !== start || this.end !== end) { 112 | this.start = start; 113 | this.end = end; 114 | this.$forceUpdate(); 115 | } 116 | } else { 117 | this.updateZoneNormally(offset); 118 | } 119 | }, 120 | filter: function filter(slots) { 121 | var _this2 = this; 122 | 123 | this.updateHeightList(); 124 | if (!slots) { 125 | slots = []; 126 | this.start = 0; 127 | } 128 | 129 | var slotList = slots.filter(function (slot, index) { 130 | return index >= _this2.start && index <= _this2.end; 131 | }); 132 | var topList = this.heightList.slice(0, this.start); 133 | var bottomList = this.heightList.slice(this.end + 1); 134 | this.total = slots.length; 135 | // consider that the height of item may change in any case 136 | // so we compute paddingTop and paddingBottom every time 137 | this.paddingTop = topList.length ? topList.reduce(function (a, b) { 138 | return a + b; 139 | }) : 0; 140 | this.paddingBottom = bottomList.length ? bottomList.reduce(function (a, b) { 141 | return a + b; 142 | }) : 0; 143 | 144 | return slotList; 145 | }, 146 | update: function update() { 147 | var _this3 = this; 148 | 149 | this.$nextTick(function () { 150 | _this3.updateZone(_this3.scrollTop); 151 | }); 152 | } 153 | }, 154 | beforeCreate: function beforeCreate() { 155 | // vue won't observe this properties 156 | Object.assign(this, { 157 | heightList: [], // list of each item height 158 | scrollTop: 0, // current scroll position 159 | start: 0, // start index 160 | end: 0, // end index 161 | total: 0, // all items count 162 | keeps: 0, // number of item keeping in real dom 163 | paddingTop: 0, // all padding of top dom 164 | paddingBottom: 0, // all padding of bottom dom 165 | reserve: 10 // number of reserve dom for pre-render 166 | }); 167 | }, 168 | beforeMount: function beforeMount() { 169 | if (this.enabled) { 170 | var remains = this.remain; 171 | this.start = 0; 172 | this.end = remains + this.reserve - 1; 173 | this.keeps = remains + this.reserve; 174 | } 175 | }, 176 | activated: function activated() { 177 | // while work with keep-alive component 178 | // set scroll position after 'activated' 179 | this.ignoreStep = true; 180 | this.$el.scrollTop = this.keep ? this.scrollTop || 1 : 1; 181 | }, 182 | render: function render(h) { 183 | var showList = this.enabled ? this.filter(this.$slots.default) : this.$slots.default; 184 | var debounce = this.debounce; 185 | 186 | return h('div', { 187 | class: ['scroll-container'], 188 | style: { 189 | 'display': 'block', 190 | 'overflow-y': 'auto', 191 | 'height': '100%' 192 | }, 193 | on: { // '&' support passive event 194 | '&scroll': debounce ? _debounce(this.handleScroll.bind(this), debounce) : this.handleScroll 195 | } 196 | }, [h('div', { 197 | style: { 198 | 'display': 'block', 199 | 'padding-top': this.paddingTop + 'px', 200 | 'padding-bottom': this.paddingBottom + 'px' 201 | } 202 | }, showList)]); 203 | } 204 | }; 205 | 206 | module.exports = component; 207 | -------------------------------------------------------------------------------- /dist/vue-scroll-list.esm.js: -------------------------------------------------------------------------------- 1 | var _debounce = function _debounce(fn, wait) { 2 | var timeoutId = null; 3 | return function () { 4 | var _this = this, 5 | _arguments = arguments; 6 | 7 | var laterFn = function laterFn() { 8 | fn.apply(_this, _arguments); 9 | }; 10 | clearTimeout(timeoutId); 11 | timeoutId = setTimeout(laterFn, wait); 12 | }; 13 | }; 14 | 15 | var component = { 16 | props: { 17 | heights: { 18 | type: Array 19 | }, 20 | remain: { 21 | type: Number, 22 | default: 10 23 | }, 24 | enabled: { 25 | type: Boolean, 26 | default: true 27 | }, 28 | keep: { 29 | type: Boolean, 30 | default: false 31 | }, 32 | debounce: { 33 | type: Number 34 | }, 35 | step: { // throttle 36 | type: Number 37 | } 38 | }, 39 | methods: { 40 | handleScroll: function handleScroll(event) { 41 | var scrollTop = this.$el.scrollTop; 42 | if (!this.ignoreStep && this.step && Math.abs(scrollTop - this.scrollTop) < this.step) return; 43 | this.ignoreStep = false; 44 | this.scrollTop = scrollTop; 45 | this.$emit('scrolling', event); 46 | this.updateZone(scrollTop); 47 | }, 48 | updateHeightList: function updateHeightList() { 49 | if (this.heights) { 50 | this.heightList = this.heights; 51 | } else { 52 | var list = this.$slots.default || []; 53 | if (list.length !== this.heightList.length) { 54 | this.heightList = list.map(function (vnode) { 55 | return parseInt(vnode.data.attrs['data-height']); 56 | }); 57 | } 58 | } 59 | }, 60 | updateZoneNormally: function updateZoneNormally(offset) { 61 | // handle the scroll event normally 62 | var scrollHeight = this.$el.scrollHeight; 63 | var clientHeight = this.$el.clientHeight; 64 | if (offset === 0) { 65 | this.$emit('toTop'); 66 | } else if (offset + clientHeight + 5 >= scrollHeight) { 67 | this.$emit('toBottom'); 68 | } 69 | }, 70 | findOvers: function findOvers(offset) { 71 | // compute overs by comparing offset with the height of each item 72 | // @todo: need to optimize this searching efficiency 73 | var heightList = this.heightList; 74 | var overs = 0; 75 | var height = heightList[0]; 76 | var topReserve = Math.floor(this.reserve / 2); 77 | for (var length = heightList.length; overs < length; overs++) { 78 | if (offset >= height) { 79 | height += heightList[overs + 1]; 80 | } else { 81 | break; 82 | } 83 | } 84 | return overs > topReserve - 1 ? overs - topReserve : 0; 85 | }, 86 | updateZone: function updateZone(offset) { 87 | if (this.enabled) { 88 | this.updateHeightList(); 89 | var overs = this.findOvers(offset); 90 | 91 | // scroll to top 92 | if (!offset && this.total) { 93 | this.$emit('toTop'); 94 | } 95 | 96 | var start = overs || 0; 97 | var end = start + this.keeps; 98 | var totalHeight = this.heightList.reduce(function (a, b) { 99 | return a + b; 100 | }); 101 | 102 | // scroll to bottom 103 | if (offset && offset + this.$el.clientHeight >= totalHeight) { 104 | start = this.total - this.keeps; 105 | end = this.total - 1; 106 | this.$emit('toBottom'); 107 | } 108 | 109 | if (this.start !== start || this.end !== end) { 110 | this.start = start; 111 | this.end = end; 112 | this.$forceUpdate(); 113 | } 114 | } else { 115 | this.updateZoneNormally(offset); 116 | } 117 | }, 118 | filter: function filter(slots) { 119 | var _this2 = this; 120 | 121 | this.updateHeightList(); 122 | if (!slots) { 123 | slots = []; 124 | this.start = 0; 125 | } 126 | 127 | var slotList = slots.filter(function (slot, index) { 128 | return index >= _this2.start && index <= _this2.end; 129 | }); 130 | var topList = this.heightList.slice(0, this.start); 131 | var bottomList = this.heightList.slice(this.end + 1); 132 | this.total = slots.length; 133 | // consider that the height of item may change in any case 134 | // so we compute paddingTop and paddingBottom every time 135 | this.paddingTop = topList.length ? topList.reduce(function (a, b) { 136 | return a + b; 137 | }) : 0; 138 | this.paddingBottom = bottomList.length ? bottomList.reduce(function (a, b) { 139 | return a + b; 140 | }) : 0; 141 | 142 | return slotList; 143 | }, 144 | update: function update() { 145 | var _this3 = this; 146 | 147 | this.$nextTick(function () { 148 | _this3.updateZone(_this3.scrollTop); 149 | }); 150 | } 151 | }, 152 | beforeCreate: function beforeCreate() { 153 | // vue won't observe this properties 154 | Object.assign(this, { 155 | heightList: [], // list of each item height 156 | scrollTop: 0, // current scroll position 157 | start: 0, // start index 158 | end: 0, // end index 159 | total: 0, // all items count 160 | keeps: 0, // number of item keeping in real dom 161 | paddingTop: 0, // all padding of top dom 162 | paddingBottom: 0, // all padding of bottom dom 163 | reserve: 10 // number of reserve dom for pre-render 164 | }); 165 | }, 166 | beforeMount: function beforeMount() { 167 | if (this.enabled) { 168 | var remains = this.remain; 169 | this.start = 0; 170 | this.end = remains + this.reserve - 1; 171 | this.keeps = remains + this.reserve; 172 | } 173 | }, 174 | activated: function activated() { 175 | // while work with keep-alive component 176 | // set scroll position after 'activated' 177 | this.ignoreStep = true; 178 | this.$el.scrollTop = this.keep ? this.scrollTop || 1 : 1; 179 | }, 180 | render: function render(h) { 181 | var showList = this.enabled ? this.filter(this.$slots.default) : this.$slots.default; 182 | var debounce = this.debounce; 183 | 184 | return h('div', { 185 | class: ['scroll-container'], 186 | style: { 187 | 'display': 'block', 188 | 'overflow-y': 'auto', 189 | 'height': '100%' 190 | }, 191 | on: { // '&' support passive event 192 | '&scroll': debounce ? _debounce(this.handleScroll.bind(this), debounce) : this.handleScroll 193 | } 194 | }, [h('div', { 195 | style: { 196 | 'display': 'block', 197 | 'padding-top': this.paddingTop + 'px', 198 | 'padding-bottom': this.paddingBottom + 'px' 199 | } 200 | }, showList)]); 201 | } 202 | }; 203 | 204 | export default component; 205 | -------------------------------------------------------------------------------- /dist/vue-scroll-list.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : 3 | typeof define === 'function' && define.amd ? define(factory) : 4 | (global['vue-scroll-list'] = factory()); 5 | }(this, (function () { 'use strict'; 6 | 7 | var _debounce = function _debounce(fn, wait) { 8 | var timeoutId = null; 9 | return function () { 10 | var _this = this, 11 | _arguments = arguments; 12 | 13 | var laterFn = function laterFn() { 14 | fn.apply(_this, _arguments); 15 | }; 16 | clearTimeout(timeoutId); 17 | timeoutId = setTimeout(laterFn, wait); 18 | }; 19 | }; 20 | 21 | var component = { 22 | props: { 23 | heights: { 24 | type: Array 25 | }, 26 | remain: { 27 | type: Number, 28 | default: 10 29 | }, 30 | enabled: { 31 | type: Boolean, 32 | default: true 33 | }, 34 | keep: { 35 | type: Boolean, 36 | default: false 37 | }, 38 | debounce: { 39 | type: Number 40 | }, 41 | step: { // throttle 42 | type: Number 43 | } 44 | }, 45 | methods: { 46 | handleScroll: function handleScroll(event) { 47 | var scrollTop = this.$el.scrollTop; 48 | if (!this.ignoreStep && this.step && Math.abs(scrollTop - this.scrollTop) < this.step) return; 49 | this.ignoreStep = false; 50 | this.scrollTop = scrollTop; 51 | this.$emit('scrolling', event); 52 | this.updateZone(scrollTop); 53 | }, 54 | updateHeightList: function updateHeightList() { 55 | if (this.heights) { 56 | this.heightList = this.heights; 57 | } else { 58 | var list = this.$slots.default || []; 59 | if (list.length !== this.heightList.length) { 60 | this.heightList = list.map(function (vnode) { 61 | return parseInt(vnode.data.attrs['data-height']); 62 | }); 63 | } 64 | } 65 | }, 66 | updateZoneNormally: function updateZoneNormally(offset) { 67 | // handle the scroll event normally 68 | var scrollHeight = this.$el.scrollHeight; 69 | var clientHeight = this.$el.clientHeight; 70 | if (offset === 0) { 71 | this.$emit('toTop'); 72 | } else if (offset + clientHeight + 5 >= scrollHeight) { 73 | this.$emit('toBottom'); 74 | } 75 | }, 76 | findOvers: function findOvers(offset) { 77 | // compute overs by comparing offset with the height of each item 78 | // @todo: need to optimize this searching efficiency 79 | var heightList = this.heightList; 80 | var overs = 0; 81 | var height = heightList[0]; 82 | var topReserve = Math.floor(this.reserve / 2); 83 | for (var length = heightList.length; overs < length; overs++) { 84 | if (offset >= height) { 85 | height += heightList[overs + 1]; 86 | } else { 87 | break; 88 | } 89 | } 90 | return overs > topReserve - 1 ? overs - topReserve : 0; 91 | }, 92 | updateZone: function updateZone(offset) { 93 | if (this.enabled) { 94 | this.updateHeightList(); 95 | var overs = this.findOvers(offset); 96 | 97 | // scroll to top 98 | if (!offset && this.total) { 99 | this.$emit('toTop'); 100 | } 101 | 102 | var start = overs || 0; 103 | var end = start + this.keeps; 104 | var totalHeight = this.heightList.reduce(function (a, b) { 105 | return a + b; 106 | }); 107 | 108 | // scroll to bottom 109 | if (offset && offset + this.$el.clientHeight >= totalHeight) { 110 | start = this.total - this.keeps; 111 | end = this.total - 1; 112 | this.$emit('toBottom'); 113 | } 114 | 115 | if (this.start !== start || this.end !== end) { 116 | this.start = start; 117 | this.end = end; 118 | this.$forceUpdate(); 119 | } 120 | } else { 121 | this.updateZoneNormally(offset); 122 | } 123 | }, 124 | filter: function filter(slots) { 125 | var _this2 = this; 126 | 127 | this.updateHeightList(); 128 | if (!slots) { 129 | slots = []; 130 | this.start = 0; 131 | } 132 | 133 | var slotList = slots.filter(function (slot, index) { 134 | return index >= _this2.start && index <= _this2.end; 135 | }); 136 | var topList = this.heightList.slice(0, this.start); 137 | var bottomList = this.heightList.slice(this.end + 1); 138 | this.total = slots.length; 139 | // consider that the height of item may change in any case 140 | // so we compute paddingTop and paddingBottom every time 141 | this.paddingTop = topList.length ? topList.reduce(function (a, b) { 142 | return a + b; 143 | }) : 0; 144 | this.paddingBottom = bottomList.length ? bottomList.reduce(function (a, b) { 145 | return a + b; 146 | }) : 0; 147 | 148 | return slotList; 149 | }, 150 | update: function update() { 151 | var _this3 = this; 152 | 153 | this.$nextTick(function () { 154 | _this3.updateZone(_this3.scrollTop); 155 | }); 156 | } 157 | }, 158 | beforeCreate: function beforeCreate() { 159 | // vue won't observe this properties 160 | Object.assign(this, { 161 | heightList: [], // list of each item height 162 | scrollTop: 0, // current scroll position 163 | start: 0, // start index 164 | end: 0, // end index 165 | total: 0, // all items count 166 | keeps: 0, // number of item keeping in real dom 167 | paddingTop: 0, // all padding of top dom 168 | paddingBottom: 0, // all padding of bottom dom 169 | reserve: 10 // number of reserve dom for pre-render 170 | }); 171 | }, 172 | beforeMount: function beforeMount() { 173 | if (this.enabled) { 174 | var remains = this.remain; 175 | this.start = 0; 176 | this.end = remains + this.reserve - 1; 177 | this.keeps = remains + this.reserve; 178 | } 179 | }, 180 | activated: function activated() { 181 | // while work with keep-alive component 182 | // set scroll position after 'activated' 183 | this.ignoreStep = true; 184 | this.$el.scrollTop = this.keep ? this.scrollTop || 1 : 1; 185 | }, 186 | render: function render(h) { 187 | var showList = this.enabled ? this.filter(this.$slots.default) : this.$slots.default; 188 | var debounce = this.debounce; 189 | 190 | return h('div', { 191 | class: ['scroll-container'], 192 | style: { 193 | 'display': 'block', 194 | 'overflow-y': 'auto', 195 | 'height': '100%' 196 | }, 197 | on: { // '&' support passive event 198 | '&scroll': debounce ? _debounce(this.handleScroll.bind(this), debounce) : this.handleScroll 199 | } 200 | }, [h('div', { 201 | style: { 202 | 'display': 'block', 203 | 'padding-top': this.paddingTop + 'px', 204 | 'padding-bottom': this.paddingBottom + 'px' 205 | } 206 | }, showList)]); 207 | } 208 | }; 209 | 210 | return component; 211 | 212 | }))); 213 | -------------------------------------------------------------------------------- /example/App.vue: -------------------------------------------------------------------------------- 1 | 19 | 37 | 46 | -------------------------------------------------------------------------------- /example/componentA.vue: -------------------------------------------------------------------------------- 1 | 23 | 88 | -------------------------------------------------------------------------------- /example/componentB.vue: -------------------------------------------------------------------------------- 1 | 4 | 9 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | vue-scroll-list 7 | 8 | 9 |
10 | 11 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App'; 3 | 4 | new Vue({ 5 | el: '#app', 6 | template: '', 7 | components: {App} 8 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-scroll-list", 3 | "version": "0.7.0", 4 | "description": "support infinite scroll list with vue", 5 | "main": "dist/vue-scroll-list.common.js", 6 | "directories": { 7 | "example": "example" 8 | }, 9 | "scripts": { 10 | "dist": "rollup -c", 11 | "buildDemo": "webpack --config webpack.config.demo.js", 12 | "dev": "webpack-dev-server --config webpack.config.dev.js --open --inline --hot" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/KyLeoHC/vue-scroll-list.git" 17 | }, 18 | "keywords": [ 19 | "vue", 20 | "infinite", 21 | "scroll-list" 22 | ], 23 | "author": "KyLeo", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/KyLeoHC/vue-scroll-list/issues" 27 | }, 28 | "homepage": "https://github.com/KyLeoHC/vue-scroll-list#readme", 29 | "devDependencies": { 30 | "babel-core": "^6.25.0", 31 | "babel-loader": "^7.1.0", 32 | "babel-plugin-external-helpers": "^6.22.0", 33 | "babel-plugin-transform-runtime": "^6.23.0", 34 | "babel-preset-es2015": "^6.24.1", 35 | "babel-runtime": "^6.23.0", 36 | "css-loader": "^0.28.4", 37 | "css-select": "^1.3.0-rc0", 38 | "html-webpack-plugin": "^2.28.0", 39 | "htmlparser2": "^3.9.2", 40 | "pretty-error": "^2.1.1", 41 | "renderkid": "^2.0.1", 42 | "rollup": "^0.49.2", 43 | "rollup-plugin-babel": "^3.0.2", 44 | "vue": "^2.3.4", 45 | "vue-loader": "^12.2.1", 46 | "vue-template-compiler": "^2.3.4", 47 | "webpack": "^3.0.0", 48 | "webpack-dev-server": "^2.5.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | 3 | const distPath = 'dist/'; 4 | 5 | export default { 6 | input: 'src/index.js', 7 | output: [{ 8 | file: distPath + 'vue-scroll-list.js', 9 | format: 'umd', 10 | name: 'vue-scroll-list' 11 | }, { 12 | file: distPath + 'vue-scroll-list.common.js', 13 | format: 'cjs' 14 | }, { 15 | file: distPath + 'vue-scroll-list.esm.js', 16 | format: 'es' 17 | }], 18 | plugins: [ 19 | babel() 20 | ] 21 | }; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const _debounce = function (fn, wait) { 2 | let timeoutId = null; 3 | return function () { 4 | const laterFn = () => { 5 | fn.apply(this, arguments); 6 | }; 7 | clearTimeout(timeoutId); 8 | timeoutId = setTimeout(laterFn, wait); 9 | }; 10 | }; 11 | 12 | const component = { 13 | props: { 14 | heights: { 15 | type: Array 16 | }, 17 | remain: { 18 | type: Number, 19 | default: 10 20 | }, 21 | enabled: { 22 | type: Boolean, 23 | default: true 24 | }, 25 | keep: { 26 | type: Boolean, 27 | default: false 28 | }, 29 | debounce: { 30 | type: Number 31 | }, 32 | step: { // throttle 33 | type: Number 34 | } 35 | }, 36 | methods: { 37 | handleScroll(event) { 38 | const scrollTop = this.$el.scrollTop; 39 | if (!this.ignoreStep && this.step && Math.abs(scrollTop - this.scrollTop) < this.step) return; 40 | this.ignoreStep = false; 41 | this.scrollTop = scrollTop; 42 | this.$emit('scrolling', event); 43 | this.updateZone(scrollTop); 44 | }, 45 | updateHeightList() { 46 | if (this.heights) { 47 | this.heightList = this.heights; 48 | } else { 49 | const list = this.$slots.default || []; 50 | if (list.length !== this.heightList.length) { 51 | this.heightList = list.map(vnode => parseInt(vnode.data.attrs['data-height'])); 52 | } 53 | } 54 | }, 55 | updateZoneNormally(offset) { 56 | // handle the scroll event normally 57 | const scrollHeight = this.$el.scrollHeight; 58 | const clientHeight = this.$el.clientHeight; 59 | if (offset === 0) { 60 | this.$emit('toTop'); 61 | } else if (offset + clientHeight + 5 >= scrollHeight) { 62 | this.$emit('toBottom'); 63 | } 64 | }, 65 | findOvers(offset) { 66 | // compute overs by comparing offset with the height of each item 67 | // @todo: need to optimize this searching efficiency 68 | const heightList = this.heightList; 69 | let overs = 0; 70 | let height = heightList[0]; 71 | let topReserve = Math.floor(this.reserve / 2); 72 | for (let length = heightList.length; overs < length; overs++) { 73 | if (offset >= height) { 74 | height += heightList[overs + 1]; 75 | } else { 76 | break; 77 | } 78 | } 79 | return overs > topReserve - 1 ? overs - topReserve : 0; 80 | }, 81 | updateZone(offset) { 82 | if (this.enabled) { 83 | this.updateHeightList(); 84 | const overs = this.findOvers(offset); 85 | 86 | // scroll to top 87 | if (!offset && this.total) { 88 | this.$emit('toTop'); 89 | } 90 | 91 | let start = overs || 0; 92 | let end = start + this.keeps; 93 | let totalHeight = this.heightList.reduce((a, b) => { 94 | return a + b; 95 | }); 96 | 97 | // scroll to bottom 98 | if (offset && offset + this.$el.clientHeight >= totalHeight) { 99 | start = this.total - this.keeps; 100 | end = this.total - 1; 101 | this.$emit('toBottom'); 102 | } 103 | 104 | if (this.start !== start || this.end !== end) { 105 | this.start = start; 106 | this.end = end; 107 | this.$forceUpdate(); 108 | } 109 | } else { 110 | this.updateZoneNormally(offset); 111 | } 112 | }, 113 | filter(slots) { 114 | this.updateHeightList(); 115 | if (!slots) { 116 | slots = []; 117 | this.start = 0; 118 | } 119 | 120 | const slotList = slots.filter((slot, index) => { 121 | return index >= this.start && index <= this.end; 122 | }); 123 | const topList = this.heightList.slice(0, this.start); 124 | const bottomList = this.heightList.slice(this.end + 1); 125 | this.total = slots.length; 126 | // consider that the height of item may change in any case 127 | // so we compute paddingTop and paddingBottom every time 128 | this.paddingTop = topList.length ? topList.reduce((a, b) => { 129 | return a + b; 130 | }) : 0; 131 | this.paddingBottom = bottomList.length ? bottomList.reduce((a, b) => { 132 | return a + b; 133 | }) : 0; 134 | 135 | return slotList; 136 | }, 137 | update() { 138 | this.$nextTick(() => { 139 | this.updateZone(this.scrollTop); 140 | }); 141 | } 142 | }, 143 | beforeCreate() { 144 | // vue won't observe this properties 145 | Object.assign(this, { 146 | heightList: [], // list of each item height 147 | scrollTop: 0, // current scroll position 148 | start: 0, // start index 149 | end: 0, // end index 150 | total: 0, // all items count 151 | keeps: 0, // number of item keeping in real dom 152 | paddingTop: 0, // all padding of top dom 153 | paddingBottom: 0, // all padding of bottom dom 154 | reserve: 10 // number of reserve dom for pre-render 155 | }); 156 | }, 157 | beforeMount() { 158 | if (this.enabled) { 159 | let remains = this.remain; 160 | this.start = 0; 161 | this.end = remains + this.reserve - 1; 162 | this.keeps = remains + this.reserve; 163 | } 164 | }, 165 | activated() { 166 | // while work with keep-alive component 167 | // set scroll position after 'activated' 168 | this.ignoreStep = true; 169 | this.$el.scrollTop = this.keep ? (this.scrollTop || 1) : 1; 170 | }, 171 | render(h) { 172 | const showList = this.enabled ? this.filter(this.$slots.default) : this.$slots.default; 173 | const debounce = this.debounce; 174 | 175 | return h('div', { 176 | class: ['scroll-container'], 177 | style: { 178 | 'display': 'block', 179 | 'overflow-y': 'auto', 180 | 'height': '100%' 181 | }, 182 | on: { // '&' support passive event 183 | '&scroll': debounce 184 | ? _debounce(this.handleScroll.bind(this), debounce) 185 | : this.handleScroll 186 | } 187 | }, [ 188 | h('div', { 189 | style: { 190 | 'display': 'block', 191 | 'padding-top': this.paddingTop + 'px', 192 | 'padding-bottom': this.paddingBottom + 'px' 193 | } 194 | }, showList) 195 | ]); 196 | } 197 | }; 198 | 199 | export default component; 200 | -------------------------------------------------------------------------------- /webpack.config.base.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | 4 | let config = { 5 | entry: { 6 | index: './example/index' 7 | }, 8 | resolve: { 9 | extensions: ['.js', '.vue'], 10 | alias: { 11 | 'vue$': 'vue/dist/vue.esm.js', 12 | 'vue-scroll-list': path.resolve(__dirname, 'src/index.js') 13 | } 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.vue$/, 19 | loader: 'vue-loader', 20 | options: {} 21 | }, 22 | { 23 | test: /\.js$/, 24 | exclude: /node_modules/, 25 | use: [ 26 | { 27 | loader: 'babel-loader', 28 | options: { 29 | babelrc: false, // don't read '.babelrc' file 30 | presets: ['es2015'], 31 | plugins: ['transform-runtime'] 32 | } 33 | } 34 | ] 35 | } 36 | ] 37 | }, 38 | plugins: [ 39 | // new webpack.LoaderOptionsPlugin({ 40 | // options: { 41 | // babel: { 42 | // presets: ['es2015'], 43 | // plugins: ['transform-runtime'] 44 | // } 45 | // } 46 | // }) 47 | ] 48 | }; 49 | 50 | module.exports = config; -------------------------------------------------------------------------------- /webpack.config.demo.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const baseConfig = require('./webpack.config.base'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | 6 | baseConfig.output = { 7 | path: path.resolve(__dirname, 'onlineDemo'), 8 | publicPath: '/vue-scroll-list/', 9 | filename: '[name].[hash].js', 10 | sourceMapFilename: '[file].map' 11 | }; 12 | 13 | baseConfig.plugins.push( 14 | new webpack.DefinePlugin({ 15 | 'process.env': { 16 | NODE_ENV: '"production"' 17 | } 18 | }) 19 | ); 20 | 21 | baseConfig.plugins.push( 22 | new webpack.optimize.UglifyJsPlugin({ 23 | compress: { 24 | warnings: false 25 | }, 26 | comments: false, 27 | sourceMap: true 28 | }) 29 | ); 30 | 31 | baseConfig.plugins.push( 32 | new HtmlWebpackPlugin({ 33 | title: 'vue-scroll-list', 34 | template: 'example/index.html', 35 | filename: 'index.html' 36 | }) 37 | ); 38 | 39 | baseConfig.devtool = 'source-map'; 40 | 41 | module.exports = baseConfig; -------------------------------------------------------------------------------- /webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const baseConfig = require('./webpack.config.base'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | 6 | baseConfig.output = { 7 | path: path.resolve(__dirname, 'build'), 8 | publicPath: '/', 9 | filename: '[name].js' 10 | }; 11 | 12 | baseConfig.plugins.push( 13 | new HtmlWebpackPlugin({ 14 | title: 'vue-scroll-list', 15 | template: 'example/index.html', 16 | filename: 'index.html' 17 | }) 18 | ); 19 | 20 | baseConfig.devServer = { 21 | host: '0.0.0.0', 22 | port: '8686', 23 | noInfo: true, 24 | disableHostCheck: true 25 | }; 26 | 27 | module.exports = baseConfig; --------------------------------------------------------------------------------