Skip to content

Instantly share code, notes, and snippets.

@AnoRebel
Created September 2, 2022 00:44
Show Gist options
  • Save AnoRebel/b221d741fdbd9602fa3fc26e59d9fd76 to your computer and use it in GitHub Desktop.
Save AnoRebel/b221d741fdbd9602fa3fc26e59d9fd76 to your computer and use it in GitHub Desktop.
A Vue 3 `PullRefresh` component copied and fixed/enhanced since I couldn't find the original repo
<template>
<div class="pull-refresh" ref="root">
<div
class="pull-refresh__track"
:style="trackStyle"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchend"
>
<div :style="getHeadStyle" class="pull-refresh__head">
<div v-if="TEXT_STATUS.includes(status)" class="pull-text">{{ getStatusText() }}</div>
<div v-if="status === 'loading'" class="pull-text-loading">{{ loadingText }}</div>
</div>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
const DEFAULT_HEAD_HEIGHT = 50;
const getScrollTop = el => {
const top = "scrollTop" in el ? el.scrollTop : el.pageYOffset;
return Math.max(top, 0);
};
function preventDefault(event: TouchEvent, isStopPropagation = false) {
if (typeof event.cancelable !== "boolean" || event.cancelable) {
event.preventDefault();
}
if (isStopPropagation) {
event.stopPropagation();
}
}
const props = defineProps({
disabled: Boolean,
successText: {
type: String,
default: "Refresh successful",
},
pullingText: {
type: String,
default: "Pull down to refresh...",
},
loosingText: {
type: String,
default: "Release to refresh...",
},
loadingText: {
type: String,
default: "Loading...",
},
pullDistance: [Number, String],
modelValue: {
type: Boolean,
default: false,
},
successDuration: {
type: [Number, String],
default: 500,
},
animationDuration: {
type: [Number, String],
default: 300,
},
headHeight: {
type: [Number, String],
default: 50, // DEFAULT_HEAD_HEIGHT = 50
},
});
const emit = defineEmits(["refresh", "update:modelValue"]);
const slots = useSlots();
let reachTop = false;
const TEXT_STATUS = ["pulling", "loosing", "success"];
const root = ref(null);
let scrollParent = useScrollParent(root);
const state = reactive({
status: "normal",
distance: 0,
duration: 0,
});
const trackStyle = ref({});
const touch = useTouch();
const getHeadStyle = computed(() => {
if (props.headHeight !== 50) {
// DEFAULT_HEAD_HEIGHT = 50
return {
height: `${props.headHeight}px`,
};
}
return {};
});
const isTouchable = () =>
state.status !== "loading" && state.status !== "success" && !props.disabled;
const ease = (distance: number) => {
const pullDistance = +(props.pullDistance || props.headHeight);
if (distance > pullDistance) {
if (distance < pullDistance * 2) {
distance = pullDistance + (distance - pullDistance) / 2;
} else {
distance = pullDistance * 1.5 + (distance - pullDistance * 2) / 4;
}
}
return Math.round(distance);
};
const setTrackStyle = () => {
return {
transitionDuration: `${state.duration}ms`,
transform: state.distance ? `translate3d(0,${state.distance}px, 0)` : "",
};
};
const setStatus = (distance: number, isLoading = false) => {
const pullDistance = +(props.pullDistance || props.headHeight);
state.distance = distance;
if (isLoading) {
state.status = "loading";
} else if (distance === 0) {
state.status = "normal";
} else if (distance < pullDistance) {
state.status = "pulling";
} else {
state.status = "loosing";
}
trackStyle.value = setTrackStyle();
};
const getStatusText = () => {
const { status } = state;
if (status === "normal") {
return "";
}
return props[`${status}Text`] || "";
};
const showSuccessTip = () => {
state.status = "success";
setTimeout(() => {
setStatus(0);
}, +props.successDuration);
};
const checkPosition = (event: TouchEvent) => {
reachTop = getScrollTop(scrollParent.value) === 0;
if (reachTop) {
state.duration = 0;
touch.start(event);
}
};
const onTouchStart = (event: TouchEvent) => {
if (isTouchable()) {
checkPosition(event);
}
};
const onTouchMove = (event: TouchEvent) => {
if (isTouchable()) {
if (!reachTop) {
checkPosition(event);
}
const { deltaY } = touch;
touch.move(event);
if (reachTop && deltaY.value >= 0 && touch.isVertical()) {
preventDefault(event);
setStatus(ease(deltaY.value));
}
}
};
const onTouchEnd = () => {
if (reachTop && touch.deltaY.value && isTouchable()) {
state.duration = +props.animationDuration;
if (state.status === "loosing") {
setStatus(+props.headHeight, true);
emit("update:modelValue", true);
// ensure value change can be watched
nextTick(() => emit("refresh"));
} else {
setStatus(0);
}
}
};
watch(
() => props.modelValue,
value => {
state.duration = +props.animationDuration;
if (value) {
setStatus(+props.headHeight, true);
} else if (slots.success || props.successText) {
showSuccessTip();
} else {
setStatus(0, false);
}
}
);
const { status, distance, duration } = toRefs(state);
</script>
<style lang="scss">
:root {
--pull-refresh-head-height: 50px;
--pull-refresh-head-font-size: 14px;
--pull-refresh-head-text-color: #969696;
--pull-refresh-loading-icon-size: 16px;
}
.pull-refresh {
overflow: hidden;
user-select: none;
height: 100%;
}
.pull-refresh__track {
position: relative;
height: 100%;
transition-property: transform;
}
.pull-refresh__head {
position: absolute;
left: 0;
width: 100%;
height: var(--pull-refresh-head-height);
overflow: hidden;
color: var(--pull-refresh-head-text-color);
font-size: var(--pull-refresh-head-font-size);
line-height: var(--pull-refresh-head-height);
text-align: center;
transform: translateY(-100%);
}
</style>
const inBrowser = typeof window !== "undefined";
const overflowScrollReg = /scroll|auto/i;
const defaultRoot = inBrowser ? window : undefined;
const isElement = (node: HTMLElement) => {
const ELEMENT_NODE_TYPE = 1;
return node.tagName !== "HTML" && node.tagName !== "BODY" && node.nodeType === ELEMENT_NODE_TYPE;
};
// https://github.com/youzan/vant/issues/3823
const getScrollParent = (el, root) => {
let node = el;
while (node && node !== root && isElement(node)) {
const { overflowY } = window.getComputedStyle(node);
if (overflowScrollReg.test(overflowY)) {
return node;
}
node = node.parentNode;
}
return root;
};
const useScrollParent = (el, root = defaultRoot) => {
const scrollParent = ref(null);
onMounted(() => {
if (el.value) {
scrollParent.value = getScrollParent(el.value, root);
}
});
return scrollParent;
};
export { useScrollParent };
const MIN_DISTANCE = 10;
const getDirection = (x: Number, y: Number) => {
if (x > y && x > MIN_DISTANCE) {
return "horizontal";
}
if (y > x && y > MIN_DISTANCE) {
return "vertical";
}
return "";
};
const useTouch = () => {
const startX = ref(0);
const startY = ref(0);
const deltaX = ref(0);
const deltaY = ref(0);
const offsetX = ref(0);
const offsetY = ref(0);
const direction = ref("");
const isVertical = () => direction.value === "vertical";
const isHorizontal = () => direction.value === "horizontal";
const reset = () => {
deltaX.value = 0;
deltaY.value = 0;
offsetX.value = 0;
offsetY.value = 0;
direction.value = "";
};
const start = (event: TouchEvent) => {
reset();
startX.value = event.touches[0].clientX;
startY.value = event.touches[0].clientY;
};
const move = (event: TouchEvent) => {
const touch = event.touches[0];
// Fix: Safari back will set clientX to negative number
deltaX.value = touch.clientX < 0 ? 0 : touch.clientX - startX.value;
deltaY.value = touch.clientY - startY.value;
offsetX.value = Math.abs(deltaX.value);
offsetY.value = Math.abs(deltaY.value);
if (!direction.value) {
direction.value = getDirection(offsetX.value, offsetY.value);
}
};
return {
move,
start,
reset,
startX,
startY,
deltaX,
deltaY,
offsetX,
offsetY,
direction,
isVertical,
isHorizontal,
};
};
export { useTouch };
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment