├── .npmrc ├── src ├── routes │ ├── +layout.js │ ├── +page.svelte │ ├── TestItem.svelte │ ├── simple_list_horizontal │ │ ├── TestItemHorizontal.svelte │ │ └── +page.svelte │ ├── page_list │ │ └── +page.svelte │ ├── changable_data │ │ └── +page.svelte │ ├── simple_list │ │ └── +page.svelte │ ├── +layout.svelte │ ├── list_store │ │ └── +page.svelte │ ├── infinite_list │ │ └── +page.svelte │ └── mock.js ├── lib │ ├── index.js │ ├── Item.svelte │ ├── VirtualScroll.svelte │ └── virtual.js ├── app.d.ts ├── app.html └── app.css ├── static └── favicon.png ├── vite.config.ts ├── .gitignore ├── CHANGELOG.md ├── tsconfig.json ├── .github └── workflows │ ├── npm-publish.yml │ └── main.yml ├── svelte.config.js ├── LICENSE ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | resolution-mode=highest 3 | -------------------------------------------------------------------------------- /src/routes/+layout.js: -------------------------------------------------------------------------------- 1 | 2 | export const prerender = true -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/v1ack/svelte-virtual-scroll-list/HEAD/static/favicon.png -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | import VirtualScroll from "./VirtualScroll.svelte" 2 | 3 | export {VirtualScroll} 4 | export default VirtualScroll 5 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()] 6 | }); 7 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /example/public/build/ 3 | dist/ 4 | types/ 5 | .idea/ 6 | .DS_Store 7 | /build 8 | /dist 9 | /.svelte-kit 10 | /package 11 | .env 12 | .env.* 13 | !.env.example 14 | vite.config.js.timestamp-* 15 | vite.config.ts.timestamp-* 16 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # svelte-virtual-scroll-list changelog 2 | 3 | ## 1.3.0 4 | 5 | - Expose `index` from VirtualScroll component 6 | 7 | ## 1.2.0 8 | 9 | - Move example to SvelteKit 10 | - Package distribution by SvelteKit too 11 | - Add classes on VS wrappers 12 | - Add example for horizontal scroll 13 | - Fix pageMode with SSR 14 | - Support Svelte 4 -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "NodeNext" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/routes/TestItem.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | {uniqueKey} Item ({height}px) 8 |
9 | 10 | 19 | -------------------------------------------------------------------------------- /src/routes/simple_list_horizontal/TestItemHorizontal.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | {uniqueKey} Item ({width}px) 8 |
9 | 10 | 21 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish-npm: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 18 16 | cache: npm 17 | registry-url: https://registry.npmjs.org/ 18 | - name: Install dependencies 19 | run: npm install 20 | - run: npm run build 21 | - run: npm publish 22 | env: 23 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 24 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-static'; 2 | import {vitePreprocess} from '@sveltejs/kit/vite'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter(), 15 | paths: { 16 | base: "/svelte-virtual-scroll-list" 17 | } 18 | } 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 vlack 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 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | build_site: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Install Node.js 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | cache: npm 19 | 20 | - name: Install dependencies 21 | run: npm install 22 | 23 | - name: build 24 | run: | 25 | npm run build 26 | touch build/.nojekyll 27 | 28 | - name: Upload Artifacts 29 | uses: actions/upload-pages-artifact@v1 30 | with: 31 | # this should match the `pages` option in your adapter-static options 32 | path: 'build/' 33 | deploy: 34 | needs: build_site 35 | runs-on: ubuntu-latest 36 | 37 | permissions: 38 | pages: write 39 | id-token: write 40 | 41 | environment: 42 | name: github-pages 43 | url: ${{ steps.deployment.outputs.page_url }} 44 | 45 | steps: 46 | - name: Deploy 47 | id: deployment 48 | uses: actions/deploy-pages@v1 -------------------------------------------------------------------------------- /src/lib/Item.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 |
38 | 39 |
40 | -------------------------------------------------------------------------------- /src/routes/page_list/+page.svelte: -------------------------------------------------------------------------------- 1 | 23 |
24 | 25 | 26 |
27 |
28 | 35 |
36 | This is a header 37 |
38 | 39 |
40 | This is a footer 41 |
42 |
43 |
44 | 45 | 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-virtual-scroll-list", 3 | "description": "Svelte lib for virtualizing lists", 4 | "author": { 5 | "name": "v1ack", 6 | "url": "https://github.com/v1ack" 7 | }, 8 | "keywords": [ 9 | "svelte", 10 | "virtual", 11 | "virtual-list", 12 | "virtual-scroll" 13 | ], 14 | "version": "1.3.0", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/v1ack/svelte-virtual-scroll-list.git" 18 | }, 19 | "scripts": { 20 | "dev": "vite dev", 21 | "build": "vite build && npm run package", 22 | "preview": "vite preview", 23 | "package": "svelte-kit sync && svelte-package && publint", 24 | "prepublishOnly": "npm run package", 25 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 26 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 27 | }, 28 | "exports": { 29 | ".": { 30 | "types": "./dist/index.d.ts", 31 | "svelte": "./dist/index.js" 32 | } 33 | }, 34 | "files": [ 35 | "dist", 36 | "!dist/**/*.test.*", 37 | "!dist/**/*.spec.*" 38 | ], 39 | "peerDependencies": { 40 | "svelte": ">=3.5.0" 41 | }, 42 | "devDependencies": { 43 | "@sveltejs/adapter-static": "^2.0.2", 44 | "@sveltejs/kit": "^1.20.4", 45 | "@sveltejs/package": "^2.0.0", 46 | "publint": "^0.1.9", 47 | "svelte": "^4.0.0", 48 | "svelte-check": "^3.4.3", 49 | "tslib": "^2.4.1", 50 | "typescript": "^5.0.0", 51 | "vite": "^4.3.6" 52 | }, 53 | "svelte": "./dist/index.js", 54 | "types": "./dist/index.d.ts", 55 | "type": "module" 56 | } 57 | -------------------------------------------------------------------------------- /src/routes/changable_data/+page.svelte: -------------------------------------------------------------------------------- 1 | 24 |
25 | 31 |
32 | This is a header 33 |
34 | 35 |
36 | This is a footer 37 |
38 |
39 |
40 | 41 | 42 | 43 | 49 | 50 | 51 | 52 | 57 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | } 6 | 7 | body { 8 | color: #333; 9 | background-color: #eee; 10 | margin: 0; 11 | padding: 8px; 12 | box-sizing: border-box; 13 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; 14 | } 15 | 16 | a { 17 | color: rgb(0, 100, 200); 18 | text-decoration: none; 19 | } 20 | 21 | a:hover { 22 | text-decoration: underline; 23 | } 24 | 25 | a:visited { 26 | color: rgb(0, 80, 160); 27 | } 28 | 29 | label { 30 | display: block; 31 | } 32 | 33 | input, button, select, textarea { 34 | font-family: inherit; 35 | font-size: inherit; 36 | -webkit-padding: 0.4em 0; 37 | padding: 0.4em; 38 | margin: 0 0 0.5em 0; 39 | box-sizing: border-box; 40 | border: 1px solid #ccc; 41 | border-radius: 2px; 42 | } 43 | 44 | input:disabled { 45 | color: #ccc; 46 | } 47 | 48 | button { 49 | padding: 6px 12px; 50 | color: #333; 51 | background-color: #fff; 52 | outline: none; 53 | border: none; 54 | border-radius: 99px; 55 | } 56 | 57 | button:disabled { 58 | color: #999; 59 | } 60 | 61 | button:not(:disabled):active { 62 | background-color: #ddd; 63 | } 64 | 65 | button:focus { 66 | border-color: #666; 67 | } 68 | 69 | .vs { 70 | background: #fff; 71 | padding: 15px; 72 | border-radius: 25px; 73 | margin: 8px 0; 74 | } 75 | 76 | .virtual-scroll-item { 77 | padding: 4px 0; 78 | } 79 | 80 | div[slot="header"], div[slot="footer"] { 81 | text-align: center; 82 | padding: 4px; 83 | } 84 | 85 | ::-webkit-scrollbar { 86 | width: 10px; 87 | } 88 | 89 | ::-webkit-scrollbar-thumb { 90 | border-radius: 20px; 91 | background: rgba(0, 0, 0, 0.2); 92 | } 93 | 94 | ::-webkit-scrollbar-track { 95 | background: #00000014; 96 | border-radius: 20px; 97 | } 98 | -------------------------------------------------------------------------------- /src/routes/simple_list/+page.svelte: -------------------------------------------------------------------------------- 1 | 32 |
33 | addNotification("bottom")} 39 | on:top={() => addNotification("top")} 40 | > 41 |
42 | This is a header set via slot 43 |
44 | 45 |
46 | This is a footer set via slot 47 |
48 |
49 |
50 | 51 | 52 |
53 | {#each Object.entries(notifications) as [id, action] (id)} 54 |
{action}
55 | {/each} 56 |
57 | 58 | 59 | 68 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | svelte-virtual-scroll-list example 25 | 26 | 27 |
28 |

svelte-virtual-scroll-list example 29 |

30 |
31 | {#each pages as page} 32 | {page.name} 37 | {/each} 38 | Source 39 |
40 | 41 |
42 | 43 | 73 | -------------------------------------------------------------------------------- /src/routes/simple_list_horizontal/+page.svelte: -------------------------------------------------------------------------------- 1 | 32 |
33 | addNotification("bottom")} 40 | on:top={() => addNotification("top")} 41 | > 42 |
43 | This is a header set via slot 44 |
45 | 46 |
47 | This is a footer set via slot 48 |
49 |
50 |
51 | 52 | 53 |
54 | {#each Object.entries(notifications) as [id, action] (id)} 55 |
{action}
56 | {/each} 57 |
58 | 59 | 60 | 78 | -------------------------------------------------------------------------------- /src/routes/list_store/+page.svelte: -------------------------------------------------------------------------------- 1 | 37 |
38 | addNotification("bottom")} 44 | on:top={() => addNotification("top")} 45 | > 46 |
47 | This is a header 48 |
49 | 50 |
51 | This is a footer 52 |
53 |
54 |
55 | 56 | 57 | 58 | 64 |
65 | {#each Object.entries(notifications) as [id, action] (id)} 66 |
{action}
67 | {/each} 68 |
69 | 70 | 71 | 76 | -------------------------------------------------------------------------------- /src/routes/infinite_list/+page.svelte: -------------------------------------------------------------------------------- 1 | 51 | 52 |
53 | asyncAddItems(false)} 59 | on:top={() => asyncAddItems()} 60 | start={30} 61 | > 62 |
63 | Loading... 64 |
65 | 66 |
67 | loading... 68 |
69 |
70 |
71 | 72 | 73 | 74 | 79 | -------------------------------------------------------------------------------- /src/routes/mock.js: -------------------------------------------------------------------------------- 1 | const lorem = ( 2 | "Что такое Lorem Ipsum?\n" + 3 | "Lorem Ipsum - это текст-\"рыба\", часто используемый в печати и вэб-дизайне. Lorem Ipsum является стандартной \"рыбой\" для текстов на латинице с начала XVI века. В то время некий безымянный печатник создал большую коллекцию размеров и форм шрифтов, используя Lorem Ipsum для распечатки образцов. Lorem Ipsum не только успешно пережил без заметных изменений пять веков, но и перешагнул в электронный дизайн. Его популяризации в новое время послужили публикация листов Letraset с образцами Lorem Ipsum в 60-х годах и, в более недавнее время, программы электронной вёрстки типа Aldus PageMaker, в шаблонах которых используется Lorem Ipsum.\n" + 4 | "\n" + 5 | "Почему он используется?\n" + 6 | "Давно выяснено, что при оценке дизайна и композиции читаемый текст мешает сосредоточиться. Lorem Ipsum используют потому, что тот обеспечивает более или менее стандартное заполнение шаблона, а также реальное распределение букв и пробелов в абзацах, которое не получается при простой дубликации \"Здесь ваш текст.. Здесь ваш текст.. Здесь ваш текст..\" Многие программы электронной вёрстки и редакторы HTML используют Lorem Ipsum в качестве текста по умолчанию, так что поиск по ключевым словам \"lorem ipsum\" сразу показывает, как много веб-страниц всё ещё дожидаются своего настоящего рождения. За прошедшие годы текст Lorem Ipsum получил много версий. Некоторые версии появились по ошибке, некоторые - намеренно (например, юмористические варианты).\n" + 7 | "\n" + 8 | "\n" + 9 | "Откуда он появился?\n" + 10 | "Многие думают, что Lorem Ipsum - взятый с потолка псевдо-латинский набор слов, но это не совсем так. Его корни уходят в один фрагмент классической латыни 45 года н.э., то есть более двух тысячелетий назад. Ричард МакКлинток, профессор латыни из колледжа Hampden-Sydney, штат Вирджиния, взял одно из самых странных слов в Lorem Ipsum, \"consectetur\", и занялся его поисками в классической латинской литературе. В результате он нашёл неоспоримый первоисточник Lorem Ipsum в разделах 1.10.32 и 1.10.33 книги \"de Finibus Bonorum et Malorum\" (\"О пределах добра и зла\"), написанной Цицероном в 45 году н.э. Этот трактат по теории этики был очень популярен в эпоху Возрождения. Первая строка Lorem Ipsum, \"Lorem ipsum dolor sit amet..\", происходит от одной из строк в разделе 1.10.32\n" + 11 | "\n" + 12 | "Классический текст Lorem Ipsum, используемый с XVI века, приведён ниже. Также даны разделы 1.10.32 и 1.10.33 \"de Finibus Bonorum et Malorum\" Цицерона и их английский перевод, сделанный H. Rackham, 1914 год.\n" + 13 | "\n" + 14 | "Где его взять?\n" + 15 | "Есть много вариантов Lorem Ipsum, но большинство из них имеет не всегда приемлемые модификации, например, юмористические вставки или слова, которые даже отдалённо не напоминают латынь. Если вам нужен Lorem Ipsum для серьёзного проекта, вы наверняка не хотите какой-нибудь шутки, скрытой в середине абзаца. Также все другие известные генераторы Lorem Ipsum используют один и тот же текст, который они просто повторяют, пока не достигнут нужный объём. Это делает предлагаемый здесь генератор единственным настоящим Lorem Ipsum генератором. Он использует словарь из более чем 200 латинских слов, а также набор моделей предложений. В результате сгенерированный Lorem Ipsum выглядит правдоподобно, не имеет повторяющихся абзацей или \"невозможных\" слов." 16 | ).split(" ") 17 | 18 | export function randomInteger(min, max) { 19 | let rand = min + Math.random() * (max - min) 20 | return Math.floor(rand) 21 | } 22 | 23 | function* SequenceGenerator() { 24 | let i = 0 25 | while (true) { 26 | yield i++ 27 | } 28 | } 29 | 30 | export function createSequenceGenerator() { 31 | const sequenceGenerator = SequenceGenerator() 32 | return () => sequenceGenerator.next().value 33 | } 34 | 35 | export function randomWord() { 36 | return lorem[randomInteger(0, lorem.length)] 37 | } 38 | 39 | export function randomString(min, max) { 40 | let s = [] 41 | for (let i = 0; i < randomInteger(min, max); i++) s.push(randomWord()) 42 | return s.join(" ") 43 | } 44 | 45 | export function asyncTimeout(time) { 46 | return new Promise(resolve => setTimeout(resolve, time)) 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # svelte-virtual-scroll-list 2 | 3 | > ⚠️ No longer maintained 4 | > 5 | > please reffer to fork https://github.com/ArcticKeaton/svelte-virtual-scroll-list 6 | 7 | [![npm](https://img.shields.io/npm/v/svelte-virtual-scroll-list?style=for-the-badge)](https://npmjs.com/package/svelte-virtual-scroll-list/) 8 | 9 | Svelte implementation of vue library [vue-virtual-scroll-list](https://github.com/tangbc/vue-virtual-scroll-list) 10 | 11 | Virtualized scrolling for big lists 12 | 13 | --- 14 | **Support dynamic both-directional lists** (see example) 15 | 16 | --- 17 | 18 | Online demo: [https://v1ack.github.io/svelte-virtual-scroll-list/](https://v1ack.github.io/svelte-virtual-scroll-list/) 19 | 20 | [Simple example in Svelte REPL](https://ru.svelte.dev/repl/eae82aab17b04420885851d58de50a2e?version=3.38.2) 21 | 22 | # Getting started 23 | 24 | ## Installing from npm 25 | 26 | `npm i svelte-virtual-scroll-list -D` 27 | 28 | or 29 | 30 | `yarn add svelte-virtual-scroll-list -D` 31 | 32 | ## Using 33 | 34 | ```html 35 | 36 | 41 |
42 | 47 |
48 | This is a header set via slot 49 |
50 |
51 | {data.text} 52 |
53 |
54 | This is a footer set via slot 55 |
56 |
57 |
58 | ``` 59 | 60 | More examples available in `example` folder 61 | 62 | # Comparing to other virtualizing components 63 | 64 | | | svelte-virtual-scroll-list | svelte-virtual-list | svelte-tiny-virtual-list | 65 | |---------------------------|----------------------------|---------------------|----------------------------------| 66 | | handle dynamic size data | + | + | - | 67 | | scroll methods (to index) | + | - | + | 68 | | infinity scrolling | two-directional | - | one-directional with another lib | 69 | | initial scroll position | + | - | + | 70 | | sticky items | - | - | + | 71 | | top/bottom slots | + | - | + | 72 | | reached top/bottom events | + | - | - | 73 | | document as a list | + | - | - | 74 | 75 | # API 76 | 77 | ## Props 78 | 79 | | prop | type | default | description | 80 | |-----------------|----------|----------------|--------------------------------------------------------------------| 81 | | data | object[] | `null` | Source for list | 82 | | key | string | `id` | Unique key for getting data from `data` | 83 | | keeps | number | `30` | Count of rendered items | 84 | | estimateSize | number | `estimateSize` | Estimate size of each item, needs for smooth scrollbar | 85 | | isHorizontal | boolean | `false` | Scroll direction | 86 | | pageMode | boolean | `false` | Let virtual list using global document to scroll through the list | 87 | | start | number | `0` | scroll position start index | 88 | | offset | number | `0` | scroll position offset | 89 | | topThreshold | number | `0` | The threshold to emit `top` event, attention to multiple calls. | 90 | | bottomThreshold | number | `0` | The threshold to emit `bottom` event, attention to multiple calls. | 91 | 92 | ## Methods 93 | 94 | Access to methods by component binding 95 |
96 | Binding example 97 | 98 | ```html 99 | 100 | 103 | 104 | 105 | 106 | ``` 107 | 108 |
109 | 110 | | method | arguments | description | 111 | |---------------------|------------------------|-----------------------------------------------------------------------------------------------------------------------| 112 | | scrollToBottom | `none` | Scroll list to bottom | 113 | | scrollToIndex | `index: number` | Set scroll position to a designated index | 114 | | scrollToOffset | `offset: number` | Set scroll position to a designated offset | 115 | | getSize | `id: typeof props.key` | Get the designated item size | 116 | | getSizes | `none` | Get the total number of stored (rendered) items | 117 | | getOffset | `none` | Get current scroll offset | 118 | | getClientSize | `none` | Get wrapper element client viewport size (width or height) | 119 | | getScrollSize | `none` | Get all scroll size (scrollHeight or scrollWidth) | 120 | | updatePageModeFront | `none` | When using page mode and virtual list root element offsetTop or offsetLeft change, you need call this method manually | 121 | 122 | ## Events 123 | 124 | | event | description | 125 | |--------|----------------------------| 126 | | scroll | Scroll event | 127 | | top | Top of the list reached | 128 | | bottom | Bottom of the list reached | 129 | 130 | ## Additional 131 | 132 | ### Get index of current rendering items 133 | 134 | ```html 135 | 136 | 142 |
143 | {data.text} {index} 144 |
145 |
146 | ``` 147 | -------------------------------------------------------------------------------- /src/lib/VirtualScroll.svelte: -------------------------------------------------------------------------------- 1 | 264 | 265 |
266 | {#if $$slots.header} 267 | 268 | 269 | 270 | {/if} 271 |
272 | {#each displayItems as dataItem, dataIndex (dataItem[key])} 273 | 278 | 279 | 280 | {/each} 281 |
282 | {#if $$slots.footer} 283 | 284 | 285 | 286 | {/if} 287 |
289 |
290 | -------------------------------------------------------------------------------- /src/lib/virtual.js: -------------------------------------------------------------------------------- 1 | /** 2 | * virtual list core calculating center 3 | */ 4 | 5 | const DIRECTION_TYPE = { 6 | FRONT: "FRONT", // scroll up or left 7 | BEHIND: "BEHIND", // scroll down or right 8 | } 9 | const CALC_TYPE = { 10 | INIT: "INIT", 11 | FIXED: "FIXED", 12 | DYNAMIC: "DYNAMIC", 13 | } 14 | const LEADING_BUFFER = 2 15 | 16 | export default class { 17 | param 18 | callUpdate 19 | firstRangeTotalSize = 0 20 | firstRangeAverageSize = 0 21 | lastCalcIndex = 0 22 | fixedSizeValue = 0 23 | calcType = CALC_TYPE.INIT 24 | offset = 0 25 | direction = "" 26 | range 27 | 28 | constructor(param, callUpdate) { 29 | this.init(param, callUpdate) 30 | } 31 | 32 | init(param, callUpdate) { 33 | // param data 34 | this.param = param 35 | this.callUpdate = callUpdate 36 | 37 | // size data 38 | this.sizes = new Map() 39 | this.firstRangeTotalSize = 0 40 | this.firstRangeAverageSize = 0 41 | this.fixedSizeValue = 0 42 | this.calcType = CALC_TYPE.INIT 43 | 44 | // scroll data 45 | this.offset = 0 46 | this.direction = "" 47 | 48 | // range data 49 | this.range = Object.create(null) 50 | if (param) { 51 | this.checkRange(0, param.keeps - 1) 52 | } 53 | 54 | // benchmark example data 55 | // this.__bsearchCalls = 0 56 | // this.__getIndexOffsetCalls = 0 57 | } 58 | 59 | destroy() { 60 | this.init(null, null) 61 | } 62 | 63 | // return current render range 64 | getRange() { 65 | const range = Object.create(null) 66 | range.start = this.range.start 67 | range.end = this.range.end 68 | range.padFront = this.range.padFront 69 | range.padBehind = this.range.padBehind 70 | return range 71 | } 72 | 73 | isBehind() { 74 | return this.direction === DIRECTION_TYPE.BEHIND 75 | } 76 | 77 | isFront() { 78 | return this.direction === DIRECTION_TYPE.FRONT 79 | } 80 | 81 | // return start index offset 82 | getOffset(start) { 83 | return (start < 1 ? 0 : this.getIndexOffset(start)) + this.param.slotHeaderSize 84 | } 85 | 86 | updateParam(key, value) { 87 | if (this.param && (key in this.param)) { 88 | // if uniqueIds change, find out deleted id and remove from size map 89 | if (key === "uniqueIds") { 90 | this.sizes.forEach((v, key) => { 91 | if (!value.includes(key)) { 92 | this.sizes.delete(key) 93 | } 94 | }) 95 | } 96 | this.param[key] = value 97 | } 98 | } 99 | 100 | // save each size map by id 101 | saveSize(id, size) { 102 | this.sizes.set(id, size) 103 | 104 | // we assume size type is fixed at the beginning and remember first size value 105 | // if there is no size value different from this at next coming saving 106 | // we think it's a fixed size list, otherwise is dynamic size list 107 | if (this.calcType === CALC_TYPE.INIT) { 108 | this.fixedSizeValue = size 109 | this.calcType = CALC_TYPE.FIXED 110 | } else if (this.calcType === CALC_TYPE.FIXED && this.fixedSizeValue !== size) { 111 | this.calcType = CALC_TYPE.DYNAMIC 112 | // it's no use at all 113 | delete this.fixedSizeValue 114 | } 115 | 116 | // calculate the average size only in the first range 117 | if (this.calcType !== CALC_TYPE.FIXED && typeof this.firstRangeTotalSize !== "undefined") { 118 | if (this.sizes.size < Math.min(this.param.keeps, this.param.uniqueIds.length)) { 119 | this.firstRangeTotalSize = [...this.sizes.values()].reduce((acc, val) => acc + val, 0) 120 | this.firstRangeAverageSize = Math.round(this.firstRangeTotalSize / this.sizes.size) 121 | } else { 122 | // it's done using 123 | delete this.firstRangeTotalSize 124 | } 125 | } 126 | } 127 | 128 | // in some special situation (e.g. length change) we need to update in a row 129 | // try going to render next range by a leading buffer according to current direction 130 | handleDataSourcesChange() { 131 | let start = this.range.start 132 | 133 | if (this.isFront()) { 134 | start = start - LEADING_BUFFER 135 | } else if (this.isBehind()) { 136 | start = start + LEADING_BUFFER 137 | } 138 | 139 | start = Math.max(start, 0) 140 | 141 | this.updateRange(this.range.start, this.getEndByStart(start)) 142 | } 143 | 144 | // when slot size change, we also need force update 145 | handleSlotSizeChange() { 146 | this.handleDataSourcesChange() 147 | } 148 | 149 | // calculating range on scroll 150 | handleScroll(offset) { 151 | this.direction = offset < this.offset ? DIRECTION_TYPE.FRONT : DIRECTION_TYPE.BEHIND 152 | this.offset = offset 153 | 154 | if (!this.param) { 155 | return 156 | } 157 | 158 | if (this.direction === DIRECTION_TYPE.FRONT) { 159 | this.handleFront() 160 | } else if (this.direction === DIRECTION_TYPE.BEHIND) { 161 | this.handleBehind() 162 | } 163 | } 164 | 165 | // ----------- public method end ----------- 166 | 167 | handleFront() { 168 | const overs = this.getScrollOvers() 169 | // should not change range if start doesn't exceed overs 170 | if (overs > this.range.start) { 171 | return 172 | } 173 | 174 | // move up start by a buffer length, and make sure its safety 175 | const start = Math.max(overs - this.param.buffer, 0) 176 | this.checkRange(start, this.getEndByStart(start)) 177 | } 178 | 179 | handleBehind() { 180 | const overs = this.getScrollOvers() 181 | // range should not change if scroll overs within buffer 182 | if (overs < this.range.start + this.param.buffer) { 183 | return 184 | } 185 | 186 | this.checkRange(overs, this.getEndByStart(overs)) 187 | } 188 | 189 | // return the pass overs according to current scroll offset 190 | getScrollOvers() { 191 | // if slot header exist, we need subtract its size 192 | const offset = this.offset - this.param.slotHeaderSize 193 | if (offset <= 0) { 194 | return 0 195 | } 196 | 197 | // if is fixed type, that can be easily 198 | if (this.isFixedType()) { 199 | return Math.floor(offset / this.fixedSizeValue) 200 | } 201 | 202 | let low = 0 203 | let middle = 0 204 | let middleOffset = 0 205 | let high = this.param.uniqueIds.length 206 | 207 | while (low <= high) { 208 | // this.__bsearchCalls++ 209 | middle = low + Math.floor((high - low) / 2) 210 | middleOffset = this.getIndexOffset(middle) 211 | 212 | if (middleOffset === offset) { 213 | return middle 214 | } else if (middleOffset < offset) { 215 | low = middle + 1 216 | } else if (middleOffset > offset) { 217 | high = middle - 1 218 | } 219 | } 220 | 221 | return low > 0 ? --low : 0 222 | } 223 | 224 | // return a scroll offset from given index, can efficiency be improved more here? 225 | // although the call frequency is very high, its only a superposition of numbers 226 | getIndexOffset(givenIndex) { 227 | if (!givenIndex) { 228 | return 0 229 | } 230 | 231 | let offset = 0 232 | let indexSize = 0 233 | for (let index = 0; index < givenIndex; index++) { 234 | // this.__getIndexOffsetCalls++ 235 | indexSize = this.sizes.get(this.param.uniqueIds[index]) 236 | offset = offset + (typeof indexSize === "number" ? indexSize : this.getEstimateSize()) 237 | } 238 | 239 | // remember last calculate index 240 | this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex - 1) 241 | this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex()) 242 | 243 | return offset 244 | } 245 | 246 | // is fixed size type 247 | isFixedType() { 248 | return this.calcType === CALC_TYPE.FIXED 249 | } 250 | 251 | // return the real last index 252 | getLastIndex() { 253 | return this.param.uniqueIds.length - 1 254 | } 255 | 256 | // in some conditions range is broke, we need correct it 257 | // and then decide whether need update to next range 258 | checkRange(start, end) { 259 | const keeps = this.param.keeps 260 | const total = this.param.uniqueIds.length 261 | 262 | // data less than keeps, render all 263 | if (total <= keeps) { 264 | start = 0 265 | end = this.getLastIndex() 266 | } else if (end - start < keeps - 1) { 267 | // if range length is less than keeps, correct it base on end 268 | start = end - keeps + 1 269 | } 270 | 271 | if (this.range.start !== start) { 272 | this.updateRange(start, end) 273 | } 274 | } 275 | 276 | // setting to a new range and rerender 277 | updateRange(start, end) { 278 | this.range.start = start 279 | this.range.end = end 280 | this.range.padFront = this.getPadFront() 281 | this.range.padBehind = this.getPadBehind() 282 | this.callUpdate(this.getRange()) 283 | } 284 | 285 | // return end base on start 286 | getEndByStart(start) { 287 | const theoryEnd = start + this.param.keeps - 1 288 | const truelyEnd = Math.min(theoryEnd, this.getLastIndex()) 289 | return truelyEnd 290 | } 291 | 292 | // return total front offset 293 | getPadFront() { 294 | if (this.isFixedType()) { 295 | return this.fixedSizeValue * this.range.start 296 | } else { 297 | return this.getIndexOffset(this.range.start) 298 | } 299 | } 300 | 301 | // return total behind offset 302 | getPadBehind() { 303 | const end = this.range.end 304 | const lastIndex = this.getLastIndex() 305 | 306 | if (this.isFixedType()) { 307 | return (lastIndex - end) * this.fixedSizeValue 308 | } 309 | 310 | // if it's all calculated, return the exactly offset 311 | if (this.lastCalcIndex === lastIndex) { 312 | return this.getIndexOffset(lastIndex) - this.getIndexOffset(end) 313 | } else { 314 | // if not, use a estimated value 315 | return (lastIndex - end) * this.getEstimateSize() 316 | } 317 | } 318 | 319 | // get the item estimate size 320 | getEstimateSize() { 321 | return this.isFixedType() ? this.fixedSizeValue : (this.firstRangeAverageSize || this.param.estimateSize) 322 | } 323 | } 324 | 325 | export function isBrowser() { 326 | return typeof document !== "undefined" 327 | } --------------------------------------------------------------------------------