Infinite scroll for Mithril
A component to handle scrolling of an "infinite" list or grid, while only drawing what is on screen (plus a bit of pre-fetching), so safe to use on mobiles.
Compatible with Mithril 1.x.
Not included (by design):
Use as npm module:
npm install --save mithril-infinite
or download/clone from Github.
For working with the examples, see the examples documentation.
Note: The parent of "scroll-view" must have a height. Also make sure that html
has a height (typically set to 100%
).
Data can be provided:
pageUrl
for referencing URLspageData
for server requestspageUrl
for referencing URLsAn example using data files:
import infinite from "mithril-infinite"
m(infinite, {
maxPages: 16,
pageUrl: pageNum => `data/page-${pageNum}.json`,
item
})
With these options we are:
A simple item function:
const item = (data, opts, index) =>
m(".item", [
m("h2", data.title),
m("div", data.body)
])
The item
function passes 3 parameters:
data
contains the loaded data from pageUrl
.opts
contains: isScrolling: Bool
, pageId: String
, pageNum: Number
, pageSize: Number
index
: the item indexData is handled per "results" page. You are free to use any data format.
You could use a JSON data object for each page, containing a list of items. For example:
[
{
"src": "cat.jpg",
"width": 500,
"height": 375
}
]
Or:
[
["red", "#ff0000"],
]
pageData
for server requestsIn most real world situations an API server will provide the data. So while passing file URLs with pageUrl
is a handy shortcut, we preferably use data requests.
import infinite from "mithril-infinite"
const pageData = pageNum =>
m.request({
method: "GET",
dataType: "jsonp",
url: dataUrl(pageNum)
})
m(infinite, {
pageData,
item
})
Demo tip: in the example "Grid" we use jsonplaceholder.typicode.com
to fetch our images:
const PAGE_ITEMS = 10
const dataUrl = pageNum =>
`http://jsonplaceholder.typicode.com/photos?_start=${(pageNum - 1) * PAGE_ITEMS}&_end=${pageNum * PAGE_ITEMS}`
async
import infinite from "mithril-infinite"
const asyncPageData = async function(pageNum) {
try {
const response = await fetch(dataUrl(pageNum))
return response.json()
} catch (ex) {
//console.log('parsing failed', ex)
}
}
m(infinite, {
pageData: asyncPageData,
item
})
import infinite from "mithril-infinite"
const returnData = () =>
[{ /* some data */ }]
m(infinite, {
pageData: returnData,
item
})
import infinite from "mithril-infinite"
const returnDelayedData = () =>
new Promise(resolve =>
setTimeout(() =>
resolve(data)
, 1000)
)
m(infinite, {
pageData: returnDelayedData,
item
})
In situations where the Infinite component needs to show different items - for instance when filtering or sorting search results - we must provide a unique key for each page. The key will enable Mithril to properly distinguish the pages.
Use option pageKey
to provide a function that returns a unique identifying string. For example:
import infinite from "mithril-infinite"
import stream from "mithril/stream"
const query = stream("")
const Search = {
view: () =>
m("div",
m("input", {
oninput: e => query(e.target.value),
value: query()
})
)
}
const MyComponent = {
view: () => {
const queryStr = query()
return m(infinite, {
before: m(Search),
pageKey: pageNum => `${pageNum}-${queryString}`,
// other options
})
}
}
To enhance the current loading behavior, we:
The item
function can now look like this:
const item = (data, opts) =>
m("a.grid-item",
m(".image-holder",
m(".image", {
oncreate: vnode => maybeShowImage(vnode, data, opts.isScrolling),
onupdate: vnode => maybeShowImage(vnode, data, opts.isScrolling)
})
)
)
// Don't load the image if the page is scrolling
const maybeShowImage = (vnode, data, isScrolling) => {
if (isScrolling || vnode.state.inited) {
return
}
// Only load the image when visible in the viewport
if (infinite.isElementInViewport({ el: vnode.dom })) {
showImage(vnode.dom, data.thumbnailUrl)
vnode.state.inited = true
}
el.style.backgroundImage = `url(${url})`
How the total page count is delivered will differ per server. jsonplaceholder.typicode.com
passes the info in the request header.
Example "Fixed" shows how to get the total page count from the request, and use that to calculate the total content height.
We place the pageData
function in the oninit
function so that we have easy access to the state.pageCount
variable:
const state = vnode.state
state.pageCount = 1
state.pageData = pageNum =>
m.request({
method: "GET",
dataType: "jsonp",
url: dataUrl(pageNum),
extract: xhr => (
// Read the total count from the header
state.pageCount = Math.ceil(parseInt(xhr.getResponseHeader("X-Total-Count"), 10) / PAGE_ITEMS),
JSON.parse(xhr.responseText)
)
})
Then pass state.pageData
to infinite
:
m(infinite, {
pageData: state.pageData,
maxPages: state.pageCount,
...
})
For a better loading experience (and data usage), images should be loaded only when they appear on the screen. To check if the image is in the viewport, you can use the function infinite.isElementInViewport({ el })
. For example:
if (infinite.isElementInViewport({ el: vnode.dom })) {
loadImage(vnode.dom, data.thumbnailUrl)
}
Images should not be shown with the <img/>
tag: while this works fine on desktop browsers, this causes redrawing glitches on iOS Safari. The solution is to use background-image
. For example:
el.style.backgroundImage = `url(${url})`
Using <table>
tags causes reflow problems. Use divs instead, with CSS styling for table features. For example:
.page {
display: table;
width: 100%;
}
.list-item {
width: 100%;
display: table-row;
}
.list-item > div {
display: table-cell;
}
See the "Paging" example.
Use processPageData
to either:
item
Simple example with a wrapper:
m(infinite, {
processPageData: (content, options) => {
return m(".my-page", content.map((data, index) => item(data, options, index)));
},
...
});
Parameter | Mandatory | Type | Default | Description |
---|---|---|---|---|
scrollView | optional | Selector String | Pass an element's selector to assign another element as scrollView | |
class | optional | String | Extra CSS class appended to mithril-infinite__scroll-view |
|
contentTag | optional | String | "div" | HTML tag for the content element |
pageTag | optional | String | "div" | HTML tag for the page element; note that pages have class mithril-infinite__page plus either mithril-infinite__page--odd or mithril-infinite__page--even |
maxPages | optional | Number | Number.MAX_VALUE |
Maximum number of pages to draw |
preloadPages | optional | Number | 1 | Number of pages to preload when the app starts; if room is available, this number will increase automatically |
axis | optional | String | "y" | The scroll axis, either "y" or "x" |
autoSize | optional | Boolean | true | Set to false to not set the width or height in CSS |
before | optional | Mithril template or component | Content shown before the pages; has class mithril-infinite__before |
|
after | optional | Mithril template or component | Content shown after the pages; has class mithril-infinite__after ; will be shown only when content exists and the last page is in view (when maxPages is defined) |
|
contentSize | optional | Number (pixels) | Use when you know the number of items to display and the height of the content, and when predictable scrollbar behaviour is desired (without jumps when content is loaded); pass a pixel value to set the size (height or width) of the scroll content, thereby overriding the dynamically calculated height; use together with pageSize |
|
setDimensions | optional | Function ({scrolled: Number, size: Number}) | Sets the initial size and scroll position of scrollView ; this function is called once |
Parameter | Mandatory | Type | Default | Description |
---|---|---|---|---|
pageUrl | either pageData or pageUrl |
Function (page: Number) => String |
Function that accepts a page number and returns a URL String | |
pageData | either pageData or pageUrl |
Function (page: Number) => Promise |
Function that fetches data; accepts a page number and returns a promise | |
item | required: either item or processPageData |
Function (data: Array, options: Object, index: Number) => Mithril Template |
Function that creates a Mithril element from received data | |
pageSize | optional | Function (content: Array) => Number |
Pass a pixel value to set the size (height or width) of each page; the function accepts the page content and returns the size | |
pageChange | optional | Function (page: Number) |
Get notified when a new page is shown | |
processPageData | required: either item or processPageData |
Function (data: Array, options: Object) => Array |
Function that maps over the page data and returns an item for each | |
getDimensions | optional | Function () => {scrolled: Number, size: Number} |
Returns an object with state dimensions of scrollView : scrolled (either scrollTop or scrollLeft) and size (either height or width); this function is called on each view update |
|
pageKey | optional | Function (page: Number) => String |
key is based on page number | Function to provide a unique key for each Page component; use this when showing dynamic page data, for instance based on sorting or filtering |
Parameter | Mandatory | Type | Default | Description |
---|---|---|---|---|
currentPage | optional | Number | Sets the current page | |
from | optional | Number | Not needed when only one page is shown (use currentPage ); use page data from this number and higher |
|
to | optional | Number | Not needed when only one page is shown (use currentPage ); Use page data to this number and lower |
All options are passed in an options object: infinite.isElementInViewport({ el, leeway })
Parameter | Mandatory | Type | Default | Description |
---|---|---|---|---|
el | required | HTML Element | The element to check | |
axis | optional | String | "y" | The scroll axis, either "y" or "x" |
leeway | optional | Number | 300 | The extended area; by default the image is already fetched when it is 100px outside of the viewport; both bottom and top leeway are calculated |
Note: The parent of "scroll-view" must have a height. Also make sure that html
has a height (typically set to 100%
).
Styles are added using j2c. This library is also used in the examples.
Element | Key | Class |
---|---|---|
Scroll view | scrollView | mithril-infinite__scroll-view |
Scroll content | scrollContent | mithril-infinite__scroll-content |
Content container | content | mithril-infinite__content |
Pages container | pages | mithril-infinite__pages |
Page | page | mithril-infinite__page |
Content before | before | mithril-infinite__before |
Content after | after | mithril-infinite__after |
State | Key | Class |
---|---|---|
Scroll view, x axis | scrollViewX | mithril-infinite__scroll-view--x |
Scroll view, y axis | scrollViewY | mithril-infinite__scroll-view--y |
Even numbered page | pageEven | mithril-infinite__page--even |
Odd numbered page | pageOdd | mithril-infinite__page--odd |
Page, now placeholder | placeholder | mithril-infinite__page--placeholder |
overflow-anchor
Some browsers use overflow-anchor to prevent content from jumping as the page loads more data above the viewport. This may conflict how Infinite inserts content in "placeholder slots".
To prevent miscalculations of content size, the "scroll content" element has style overflow-anchor: none
.
Minified and gzipped: ~ 3.9 Kb
MIT