@rucebee/recycler-vue
Version:
Android RecyclerView port for Vue.js
654 lines (501 loc) • 18 kB
JavaScript
import findIndex from 'lodash/findIndex'
import findLast from 'lodash/findLast'
import noop from 'lodash/noop'
import timeout from '@rucebee/utils/src/timeout'
export function AbstractSource() {
const list = []
let recycler
this.attached = false
this.list = list
this.getItem = position => list[position]
this.itemCount = () => list.length
this.indexOf = (item, fromIndex) => list.indexOf(item, fromIndex)
this.findIndex = function () {
return findIndex(list, ...arguments)
}
this.insert = (position, ...items) => {
if (typeof position !== 'number') {
position = this.indexOf(position)
if (position < 0) return
if (typeof items[0] === 'number')
position += items.shift()
}
list.splice(position, 0, ...items)
this.onInsert(position, items.length)
return this
}
this.remove = (position, count = 1) => {
if (typeof position !== 'number') {
position = this.indexOf(position)
if (position < 0) return
}
list.splice(position, count)
this.onRemove(position, count)
return this
}
this.reset = (_list) => {
if (list.length)
this.onRemove(0, list.length)
list.length = 0
if (_list?.length) {
list.push(..._list)
this.onInsert(0, _list.length)
}
return this
}
this.attach = _recycler => {
if (recycler !== _recycler) {
this.onRecyclerChanged(recycler, _recycler)
this.recycler = recycler = _recycler
this.recyclerDataset = recycler.onDatasetChanged
this.recyclerUpdate = recycler.onUpdate
this.recyclerInsert = recycler.onInsert
this.recyclerRemove = recycler.onRemove
this.triggerUpdate = recycler.update
this.startPosition = recycler.startPosition
this.endPosition = recycler.endPosition
this.recyclerDataset()
if (!this.attached) {
this.attached = true
this.onAttach()
}
}
}
this.detach = _recycler => {
if (_recycler && recycler !== _recycler) return
if (recycler) this.onRecyclerChanged(recycler, null)
this.recycler = recycler = null
if (this.attached) {
this.attached = false
this.onDetach()
}
this.recyclerDataset = noop
this.recyclerUpdate = noop
this.recyclerInsert = noop
this.recyclerRemove = noop
this.triggerUpdate = noop
this.startPosition = () => 0
this.endPosition = () => -1
}
this.detach()
}
AbstractSource.prototype.onRecyclerChanged = noop
AbstractSource.prototype.onAttach = noop
AbstractSource.prototype.onDetach = noop
AbstractSource.prototype.onUpdate = function (position, count) {
this.recyclerUpdate(position, count)
}
AbstractSource.prototype.onInsert = function (position, count) {
this.recyclerInsert(position, count)
}
AbstractSource.prototype.onRemove = function (position, count) {
this.recyclerRemove(position, count)
}
AbstractSource.prototype.update = function (...items) {
for (const item of items) {
const position = typeof item !== 'number' ? this.indexOf(item) : item
if (position > -1) this.onUpdate(position, 1)
}
return this
}
AbstractSource.prototype.each = function (fn) {
for (let i = 0; i < this.list.length; i++)
fn(this.list[i], i)
return this
}
function PeriodicRefresh(query, period) {
let before = 0, nextTimeout = timeout(0), attached = false
const next = () => {
const now = Date.now()
if (before <= now) {
this.query()
} else if (period && !this.request) {
nextTimeout.stop()
nextTimeout = timeout(before - now + 1000)
nextTimeout.then(() => {
if (attached) this.query()
}, noop)
}
}
this.request = null
this.attach = () => {
if (!attached) {
attached = true
next()
}
}
this.detach = () => {
if (attached) {
attached = false
nextTimeout.stop()
}
}
this.period = _period => {
period = _period
if (period) {
before = Date.now() + period
next()
} else {
nextTimeout.stop()
}
}
this.query = dirty => {
if (this.request) {
if (dirty)
before = 0
} else if (!attached) {
before = 0
} else {
before = period ? Date.now() + period : Number.MAX_SAFE_INTEGER
nextTimeout.stop(true)
this.request = query().then(() => {
this.request = null
if (attached) next()
}).catch(err => {
console.error(err)
if (attached) {
nextTimeout = timeout(5000, () => {
this.request = null
})
nextTimeout.then(() => {
if (attached) this.query()
}, noop)
} else {
this.request = null
before = 0
}
throw err
})
}
}
}
export function ListSource(query, period) {
AbstractSource.call(this)
const list = this.list,
refresh = new PeriodicRefresh(() =>
query.call(this).then(_list => {
this.onRemove(0, list.length)
list.length = 0
this.insert(0, ..._list)
}), period)
this.onAttach = refresh.attach
this.onDetach = refresh.detach
this.refresh = refresh.query
}
ListSource.prototype = Object.create(AbstractSource.prototype)
ListSource.prototype.constructor = ListSource
function subscribeRecyclerLaidout(from, to) {
if (from) from.$off('laidout', this.recyclerLaidout)
if (to) to.$on('laidout', this.recyclerLaidout)
}
export function WaterfallSource(query, limit, loadingItem) {
AbstractSource.call(this)
const list = this.list, viewDistance = limit >> 1,
refresh = new PeriodicRefresh(() => {
const item = findLast(list, 'id', list.length - 2)
return query.call(this, item, limit).then(_list => {
if (item?.id !== findLast(list, 'id', list.length - 2)?.id) return
if (_list?.length) {
this.insert(list.length - 1, ..._list)
const startPos = this.startPosition()
if (startPos > limit)
this.remove(0, startPos - viewDistance)
this.triggerUpdate()
} else if (loading) {
loading = false
this.remove(loadingItem)
}
})
}, 0)
let loading = false, attached = false
this.onAttach = () => {
attached = true
if (!loading) {
loading = true
this.insert(list.length, loadingItem)
}
refresh.attach()
}
this.onDetach = () => {
attached = false
refresh.detach()
}
this.reset = () => {
const len = list.length
this.onRemove(0, len)
list.length = 0
if (attached) {
this.insert(list.length, loadingItem)
loading = true
}
}
this.recyclerLaidout = (position, hs) => {
if (loading && position + hs.length - 1 + viewDistance >= list.length)
refresh.query()
}
this.cut = position => {
let len = list.length - position - 2
if (loading) len--
if (len < 1) return
this.remove(position + 1, len)
if (!loading) {
loading = true
this.insert(list.length, loadingItem)
}
this.triggerUpdate()
}
}
WaterfallSource.prototype = Object.create(AbstractSource.prototype)
WaterfallSource.prototype.constructor = WaterfallSource
WaterfallSource.prototype.onRecyclerChanged = subscribeRecyclerLaidout
export function HistorySource(queryNext, queryHistory, limit, loadingItem, fromItem = null, period = 0, historyItem) {
AbstractSource.call(this)
if (!historyItem) historyItem = loadingItem
const autoHistory = historyItem.type === 'loading'
let firstIndex = 1, enabled = true, attached = false
const list = this.list, viewDistance = limit >> 1,
cutHistory = () => {
const startPos = this.startPosition()
if (startPos > limit && startPos - viewDistance < list.length - firstIndex) {
this.remove(firstIndex, startPos - viewDistance)
if (!firstIndex) {
firstIndex = 1
this.insert(0, historyItem)
}
//console.log('cutHistory', firstIndex, startPos - viewDistance, list[firstIndex])
return true
}
},
nextRefresh = new PeriodicRefresh(() => queryNext.call(this, list.length <= firstIndex ? fromItem : list[list.length - 1], limit).then(_list => {
//console.log('nextRefresh', {list, _list})
if (!fromItem && firstIndex) {
this.remove(0, 1)
this.insert(0, historyItem)
}
if (_list.length) {
if (list.length <= firstIndex) {
this.insert(list.length, ..._list)
} else {
this.insert(list.length, ..._list)
if (!historyRefresh.request) cutHistory()
}
if (_list.length >= limit) {
if (!fromItem) nextRefresh.query(true)
} else {
if (firstIndex) {
firstIndex = 0
this.remove(0, 1)
}
if (fromItem) {
fromItem = null
nextRefresh.period(period)
}
}
} else if (list.length <= firstIndex && firstIndex) {
firstIndex = 0
this.remove(0, 1)
} else if (!historyRefresh.request) {
cutHistory()
}
}), fromItem ? 0 : period),
historyRefresh = new PeriodicRefresh(() => {
if (!firstIndex || list.length <= firstIndex) return Promise.resolve()
const item = list[firstIndex]
return queryHistory.call(this, list[firstIndex], limit).then(_list => {
if (!_list
|| !firstIndex
|| list.length <= firstIndex
|| item.id !== list[firstIndex].id
|| cutHistory()) return
if (_list.length) this.insert(firstIndex, ..._list)
if (_list.length < limit) {
firstIndex = 0
this.remove(0)
} else {
this.triggerUpdate()
}
})
}, 0)
list.push(loadingItem)
this.empty = () => list.length <= firstIndex
this.refresh = nextRefresh.query
this.refreshHistory = () => {
historyRefresh.query()
}
this._onAttach = () => {
attached = true
if (enabled) {
nextRefresh.attach()
historyRefresh.attach()
}
}
this._onDetach = () => {
attached = false
nextRefresh.detach()
historyRefresh.detach()
}
this.setEnabled = _enabled => {
enabled = _enabled
if (attached) {
if (enabled) this.onAttach()
else this.onDetach()
}
}
this.recyclerLaidout = (position, hs) => {
//console.log('recyclerLaidout', {position, hs, firstIndex, list, fromItem})
if (firstIndex && list.length > firstIndex) {
if (autoHistory && position <= viewDistance)
historyRefresh.query()
if (fromItem && position + hs.length - 1 + viewDistance >= list.length)
nextRefresh.query()
}
}
}
HistorySource.prototype = Object.create(AbstractSource.prototype)
HistorySource.prototype.constructor = HistorySource
HistorySource.prototype.onAttach = function () {
this._onAttach()
}
HistorySource.prototype.onDetach = function () {
this._onDetach()
}
HistorySource.prototype.onRecyclerChanged = subscribeRecyclerLaidout
export function ProxySource(...srcs) {
AbstractSource.call(this)
if(Array.isArray(srcs[0])) {
const fnNames = srcs.shift()
fnNames.forEach((name) => {
this[name] = (item) => {
for (const src of srcs)
if (src.indexOf(item) > -1) return src[name](item)
}
})
}
this.srcs = srcs
const self = this, list = this.list
let attached = false, maxCount = null
const recyclerProxy = (src, index) => ({
onDatasetChanged: () => {
list.length = 0
for (let i = 0; i < srcs.length; i++) {
list.push(...srcs[i].list)
}
this.recycler.onDatasetChanged()
},
onUpdate: (position, count) => {
let base = 0
for (let i = 0; i < index; i++)
base += srcs[i].itemCount()
list.splice(base + position, count, ...src.list.slice(position, position + count))
this.recycler.onUpdate(base + position, count)
this.recycler.$emit('changed', index, src, base)
},
onInsert: (position, count) => {
let base = 0
for (let i = 0; i < index; i++)
base += srcs[i].itemCount()
//console.log('onInsert', {position, count, base, index})
list.splice(base + position, 0, ...src.list.slice(position, position + count))
this.recycler.onInsert(base + position, count)
this.recycler.$emit('changed', index, src, base)
},
onRemove: (position, count) => {
let base = 0
for (let i = 0; i < index; i++)
base += srcs[i].itemCount()
//console.log('onRemove', {position, count, base})
list.splice(base + position, count)
this.recycler.onRemove(base + position, count)
this.recycler.$emit('changed', index, src, base)
},
update: () => {
this.recycler.update()
},
startPosition: () => {
let base = 0
for (let i = 0; i < index; i++)
base += srcs[i].itemCount()
return this.recycler.startPosition() - base
},
endPosition: () => {
let base = 0
for (let i = 0; i < index; i++)
base += srcs[i].itemCount()
return this.recycler.endPosition() - base
},
$on: noop,
$off: noop,
$emit: (...args) => this.recycler.$emit(...args),
$notify: (...args) => this.recycler.$notify(...args),
get $router() {
return self.recycler.$router
}
})
this.setMaxCount = (count = null) => {
const prevCount = maxCount
maxCount = count
if (!this.recycler) return
if (count === null) {
if (prevCount !== null && this.list.length > prevCount)
this.recycler.onInsert(prevCount, this.list.length - prevCount)
} else if (count < this.list.length) {
this.recycler.onRemove(count, this.list.length - count)
}
}
this.getMaxCount = () => maxCount
this.itemCount = () => {
return maxCount !== null ? Math.min(maxCount, list.length) : list.length
}
this.getBase = index => {
if (index >= srcs.length) index = srcs.length - 1
let base = 0
for (let i = 0; i < index; i++) {
//console.log({i, count: srcs[i].itemCount()})
base += srcs[i].itemCount()
}
return base
}
this._onAttach = () => {
attached = true
list.length = 0
for (let i = 0; i < srcs.length; i++) {
list.push(...srcs[i].list)
srcs[i].attach(recyclerProxy(srcs[i], i))
}
}
this._onDetach = () => {
attached = false
for (let i = 0; i < srcs.length; i++)
srcs[i].detach()
}
this.recyclerLaidout = (position, hs) => {
let base = 0
for (let i = 0; i < srcs.length; i++) {
const src = srcs[i]
if (src.recyclerLaidout) {
if (position + hs.length - 1 >= base && position < base + src.itemCount())
src.recyclerLaidout(
//position < base ? 0 : position - base,
Math.max(0, position - base),
hs.slice(
//position < base ? base - position : 0,
Math.max(0, base - position),
//position + hs.length, base + src.itemCount() < position + hs.length ? hs.length - base + src.itemCount() - position : hs.length,
hs.length - Math.max(0, base - src.itemCount() + position),
),
base
)
}
base += src.itemCount()
}
}
}
ProxySource.prototype = Object.create(AbstractSource.prototype)
ProxySource.prototype.constructor = ProxySource
ProxySource.prototype.onAttach = function () {
this._onAttach()
}
ProxySource.prototype.onDetach = function () {
this._onDetach()
}
ProxySource.prototype.onRecyclerChanged = subscribeRecyclerLaidout