vuescroll
Version:
A beautiful scrollbar based on Vue.js for PC and mobile.
492 lines (480 loc) • 14.6 kB
JavaScript
import { listenResize } from '../third-party/resize-detector';
import hackLifecycle from '../mixins/hack-lifecycle';
import api from '../mixins/api';
import nativeMode from '../mixins/mode/native-mode';
import slideMode from '../mixins/mode/slide-mode';
import bar, { createBar } from './child-components/vuescroll-bar';
import scrollContent from './child-components/vuescroll-content';
import scrollPanel, { createPanel } from './child-components/vuescroll-panel';
import { smallChangeArray } from '../shared/constants';
import {
isChildInParent,
isSupportTouch,
insertChildrenIntoSlot
} from '../util';
function findValuesByMode(mode, vm) {
let axis = {};
switch (mode) {
case 'native':
case 'pure-native':
axis = {
x: vm.scrollPanelElm.scrollLeft,
y: vm.scrollPanelElm.scrollTop
};
break;
case 'slide':
axis = { x: vm.scroller.__scrollLeft, y: vm.scroller.__scrollTop };
break;
}
return axis;
}
const vueScrollCore = {
name: 'vueScroll',
components: { bar, scrollContent, scrollPanel },
props: {
ops: { type: Object }
},
mixins: [hackLifecycle, api, nativeMode, slideMode],
mounted() {
if (!this.renderError) {
this.initVariables();
this.initWatchOpsChange();
this.refreshInternalStatus();
this.$nextTick(() => {
if (!this._isDestroyed) {
// update again to make sure bar's size is correct.
this.updateBarStateAndEmitEvent();
this.scrollToAnchor();
}
}, 0);
}
},
beforeDestroy() {
// remove registryed resize
if (this.destroyParentDomResize) {
this.destroyParentDomResize();
this.destroyParentDomResize = null;
}
if (this.destroyResize) {
this.destroyResize();
this.destroyResize = null;
}
},
data() {
return {
/**
* @description
* In state props of each components, we store the states of each
* components, and in mergedOptions props, we store the options
* that are megred from user-defined options to default options.
* @author wangyi7099
* @returns
*/
vuescroll: {
state: {
isDragging: false,
isClickingBar: false,
pointerLeave: true,
internalScrollTop: 0,
internalScrollLeft: 0,
posX: null,
posY: null,
refreshStage: 'deactive',
loadStage: 'deactive',
height: '100%',
width: '100%'
}
},
bar: {
vBar: {
state: {
posValue: 0,
size: 0,
opacity: 0
}
},
hBar: {
state: {
posValue: 0,
size: 0,
opacity: 0
}
},
renderError: false
}
};
},
render(h) {
let vm = this;
if (vm.renderError) {
return <div>{[vm.$slots['default']]}</div>;
}
// vuescroll data
const vuescrollData = {
style: {
height: vm.vuescroll.state.height,
width: vm.vuescroll.state.width,
padding: 0
},
class: 'vuescroll'
};
if (!isSupportTouch()) {
vuescrollData.on = {
mouseenter() {
vm.vuescroll.state.pointerLeave = false;
vm.updateBarStateAndEmitEvent();
},
mouseleave() {
vm.vuescroll.state.pointerLeave = true;
vm.hideBar();
},
mousemove() /* istanbul ignore next */ {
vm.vuescroll.state.pointerLeave = false;
vm.updateBarStateAndEmitEvent();
}
};
} /* istanbul ignore next */ else {
vuescrollData.on = {
touchstart() {
vm.vuescroll.state.pointerLeave = false;
vm.updateBarStateAndEmitEvent();
},
touchend() {
vm.vuescroll.state.pointerLeave = true;
vm.hideBar();
},
touchmove() /* istanbul ignore next */ {
vm.vuescroll.state.pointerLeave = false;
vm.updateBarStateAndEmitEvent();
}
};
}
const customContainer = this.$slots['scroll-container'];
const ch = [
createPanel(h, vm),
createBar(h, vm, 'vertical'),
createBar(h, vm, 'horizontal')
];
if (customContainer) {
return insertChildrenIntoSlot(h, customContainer, ch, vuescrollData);
}
return <div {...vuescrollData}>{ch}</div>;
},
computed: {
scrollPanelElm() {
return this.$refs['scrollPanel']._isVue
? this.$refs['scrollPanel'].$el
: this.$refs['scrollPanel'];
},
scrollContentElm() {
return this.$refs['scrollContent']._isVue
? this.$refs['scrollContent'].$el
: this.$refs['scrollContent'];
},
mode() {
return this.mergedOptions.vuescroll.mode;
},
pullRefreshTip() {
return this.mergedOptions.vuescroll.pullRefresh.tips[
this.vuescroll.state.refreshStage
];
},
pushLoadTip() {
return this.mergedOptions.vuescroll.pushLoad.tips[
this.vuescroll.state.loadStage
];
},
refreshLoad() {
return (
this.mergedOptions.vuescroll.pullRefresh.enable ||
this.mergedOptions.vuescroll.pushLoad.enable
);
}
},
methods: {
updateBarStateAndEmitEvent(eventType, nativeEvent = null) {
if (this.mode == 'native' || this.mode == 'pure-native') {
this.updateNativeModeBarState();
} else if (this.mode == 'slide') {
if (!this.scroller) {
return;
}
this.updateSlideModeBarState();
}
if (eventType) {
this.emitEvent(eventType, nativeEvent);
}
this.showAndDefferedHideBar();
},
updateMode() {
const x = this.vuescroll.state.internalScrollLeft;
const y = this.vuescroll.state.internalScrollTop;
if (this.destroyScroller) {
this.scroller.stop();
this.destroyScroller();
this.destroyScroller = null;
}
if (this.mode == 'slide') {
this.destroyScroller = this.registryScroller();
} else if (this.mode == 'native' || this.mode == 'pure-native') {
// remove the legacy transform style attribute
this.scrollPanelElm.style.transform = '';
this.scrollPanelElm.style.transformOrigin = '';
}
// keep the last-mode's position.
this.scrollTo({ x, y }, false, true /* force */);
},
handleScroll(nativeEvent) {
this.recordCurrentPos();
this.updateBarStateAndEmitEvent('handle-scroll', nativeEvent);
},
setBarClick(val) {
/* istanbul ignore next */
this.vuescroll.state.isClickingBar = val;
},
showAndDefferedHideBar() {
this.showBar();
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = 0;
}
this.timeoutId = setTimeout(() => {
this.timeoutId = 0;
this.hideBar();
}, this.mergedOptions.bar.showDelay);
},
/**
* emit user registry event
*/
emitEvent(eventType, nativeEvent = null) {
let {
scrollHeight,
scrollWidth,
clientHeight,
clientWidth,
scrollTop,
scrollLeft
} = this.scrollPanelElm;
const vertical = {
type: 'vertical'
};
const horizontal = {
type: 'horizontal'
};
if (this.mode == 'slide') {
scrollHeight = this.scroller.__contentHeight;
scrollWidth = this.scroller.__contentWidth;
scrollTop = this.scroller.__scrollTop;
scrollLeft = this.scroller.__scrollLeft;
clientHeight = this.$el.clientHeight;
clientWidth = this.$el.clientWidth;
}
vertical['process'] = Math.min(
scrollTop / (scrollHeight - clientHeight),
1
);
horizontal['process'] = Math.min(
scrollLeft / (scrollWidth - clientWidth),
1
);
vertical['barSize'] = this.bar.vBar.state.size;
horizontal['barSize'] = this.bar.hBar.state.size;
vertical['scrollTop'] = scrollTop;
horizontal['scrollLeft'] = scrollLeft;
vertical['directionY'] = this.vuescroll.state.posY;
horizontal['directionX'] = this.vuescroll.state.posX;
this.$emit(eventType, vertical, horizontal, nativeEvent);
},
showBar() {
this.bar.vBar.state.opacity = this.mergedOptions.bar.vBar.opacity;
this.bar.hBar.state.opacity = this.mergedOptions.bar.hBar.opacity;
},
hideBar() {
// when in non-native mode dragging content
// in slide mode, just return
/* istanbul ignore next */
if (this.vuescroll.state.isDragging) {
return;
}
// add isClickingBar condition
// to prevent from hiding bar while dragging the bar
if (
!this.mergedOptions.bar.vBar.keepShow &&
!this.vuescroll.state.isClickingBar &&
this.vuescroll.state.pointerLeave
) {
this.bar.vBar.state.opacity = 0;
}
if (
!this.mergedOptions.bar.hBar.keepShow &&
!this.vuescroll.state.isClickingBar &&
this.vuescroll.state.pointerLeave
) {
this.bar.hBar.state.opacity = 0;
}
},
registryResize() {
/* istanbul ignore next */
if (this.destroyResize) {
// when toggling the mode
// we should clean the flag-object.
this.destroyResize();
}
let contentElm = null;
if (this.mode == 'slide' || this.mode == 'pure-native') {
contentElm = this.scrollPanelElm;
} else if (this.mode == 'native') {
// scrollContent maybe a component or a pure-dom
contentElm = this.scrollContentElm;
}
const handleWindowResize = () => /* istanbul ignore next */ {
this.updateBarStateAndEmitEvent();
if (this.mode == 'slide') {
this.updateScroller();
}
};
const handleDomResize = () => {
let currentSize = {};
if (this.mode == 'slide') {
this.updateScroller();
currentSize['width'] = this.scroller.__contentWidth;
currentSize['height'] = this.scroller.__contentHeight;
} else if (this.mode == 'native' || this.mode == 'pure-native') {
currentSize['width'] = this.scrollPanelElm.scrollWidth;
currentSize['height'] = this.scrollPanelElm.scrollHeight;
}
this.updateBarStateAndEmitEvent('handle-resize', currentSize);
};
window.addEventListener('resize', handleWindowResize, false);
const destroyDomResize = listenResize(contentElm, handleDomResize);
const destroyWindowResize = () => {
window.removeEventListener('resize', handleWindowResize, false);
};
this.destroyResize = () => {
destroyWindowResize();
destroyDomResize();
};
},
registryParentResize() {
this.destroyParentDomResize = listenResize(
this.$el.parentNode,
this.useNumbericSize
);
},
useNumbericSize() {
const parentElm = this.$el.parentNode;
const { position } = parentElm.style;
if (!position || position == 'static') {
this.$el.parentNode.style.position = 'relative';
}
this.vuescroll.state.height = parentElm.offsetHeight + 'px';
this.vuescroll.state.width = parentElm.offsetWidth + 'px';
},
usePercentSize() {
this.vuescroll.state.height = '100%';
this.vuescroll.state.width = '100%';
},
// set its size to be equal to its parentNode
setVsSize() {
if (this.mergedOptions.vuescroll.sizeStrategy == 'number') {
this.useNumbericSize();
this.registryParentResize();
} else if (this.mergedOptions.vuescroll.sizeStrategy == 'percent') {
if (this.destroyParentDomResize) {
this.destroyParentDomResize();
this.destroyParentDomResize = null;
}
this.usePercentSize();
}
},
recordCurrentPos() {
let mode = this.mode;
if (this.mode !== this.lastMode) {
mode = this.lastMode;
this.lastMode = this.mode;
}
const state = this.vuescroll.state;
let axis = findValuesByMode(mode, this);
const oldX = state.internalScrollLeft;
const oldY = state.internalScrollTop;
state.posX =
oldX - axis.x > 0 ? 'right' : oldX - axis.x < 0 ? 'left' : null;
state.posY = oldY - axis.y > 0 ? 'up' : oldY - axis.y < 0 ? 'down' : null;
state.internalScrollLeft = axis.x;
state.internalScrollTop = axis.y;
},
refreshInternalStatus() {
// 1.set vuescroll height or width according to
// sizeStrategy
this.setVsSize();
// 2. registry resize event
this.registryResize();
// 3. registry scroller if mode is 'slide'
// or remove 'transform origin' is the mode is not `slide`
this.updateMode();
// 4. update scrollbar's height/width
this.updateBarStateAndEmitEvent();
},
initWatchOpsChange() {
const watchOpts = {
deep: true,
sync: true
};
this.$watch(
'mergedOptions',
() => {
// record current position
this.recordCurrentPos();
setTimeout(() => {
if (this.isSmallChangeThisTick == true) {
this.isSmallChangeThisTick = false;
this.updateBarStateAndEmitEvent();
return;
}
this.refreshInternalStatus();
}, 0);
},
watchOpts
);
smallChangeArray.forEach(opts => {
this.$watch(
opts,
() => {
// when small changes changed,
// we need not to updateMode or registryResize
this.isSmallChangeThisTick = true;
},
watchOpts
);
});
},
// scrollTo hash-anchor while mounted
scrollToAnchor() /* istanbul ignore next */ {
const validateHashSelector = function(hash) {
return /^#[a-zA-Z_]\d*$/.test(hash);
};
let hash = window.location.hash;
if (
!hash ||
((hash = hash.slice(hash.lastIndexOf('#'))) &&
!validateHashSelector(hash))
) {
return;
}
const elm = document.querySelector(hash);
if (
!isChildInParent(elm, this.$el) ||
this.mergedOptions.scrollPanel.initialScrollY ||
this.mergedOptions.scrollPanel.initialScrollX
) {
return;
}
this.scrollIntoView(elm);
},
initVariables() {
this.lastMode = this.mode;
this.$el._isVuescroll = true;
}
}
};
export default vueScrollCore;