[vue] Worry-saving and labor-saving skeleton screen 2, list skeleton screen

[vue] Worry-saving and labor-saving skeleton screen 2, list skeleton screen

vue3 skeleton screen + pull up to load more packages

Introduction, introduction

The skeleton screen of this list had an initial version before, and I wrote a blog once, but it was posted on the csdn, and then changed once, because the reading volume of that blog post was not high, and there was no motivation to update it. The main difference between the first version and the current version is that the implementation idea is different. Before, the skeleton screen was used as a module, and then the specific list rendering was another module. When switching, the whole is cut. Although there is a transition, it is not particularly natural. The second version adopts the same scheme as my previous blog post, a worry-saving skeleton screen , which replaces css. The writing method is also different. This time I refer to the vant list writing method of the Youzan team (copy a lot of code), which is equivalent to adding a skeleton screen function to the vant list component. Thanks to the vant team.

Features

Provides skeleton screen display and waterfall stream scrolling loading for displaying long lists. When the list is about to scroll to the bottom, an event will be triggered and more list items will be loaded.

Effect (when selecting outlets)

usage

< div class = "router_view" style = "height:800px;overflow-y: auto;" > < ListView :list-data = "data" :bind-scroll-document = "routerView" :empty-item = "emptyItem" :finished = "finished" v-model:loading = "showLoading" :error = "showError" @ load = "requestData" v-slot = "{ item }" > <div class = "item row-center item-between"> < img class = "item_pic" :src = "item.full_photo" /> < div class = "col center_info" > < span class = "name" > Contact: {{ item.concact_name }} </span > < span class = "time" > Fill in time: {{ item.collection_time }} </span > </div > < router-link :to = "{ path:'/form', query: {mode:'edit_draft',fileId:item.fileid }}" class = " edit_text " > Edit </Router-Link </div > </the ListView > </div > copy the code
<script lang= 'ts' > import {defineComponent, reactive, toRefs} from "vue" ; import ListView from "@/components/list_view/list_view.vue" ; export default defineComponent({ name : "" , components : { ListView, }, setup () { const state = reactive({ list : [], showLoading : true , showError : false , finished : false }); //Set the data template used by the skeleton screen, mainly used to open the span tag const emptyItem = { full_photo : "" , concact_name : "asdasd" , collection_time : "2021-3-3 15:23" , }; //Bind a slidable container, by default it is window, which is the default sliding of the browser //If the restricted list is to slide within a certain element, you need to pass this slidable element to the ListView component //Used to bind the sliding event, if it is not passed, it will be fine const routerView = document .querySelector( ".router_view" ); const requestData = () => { //Update data asynchronously //setTimeout is just an example, in real scenarios it is usually an ajax request setTimeout ( () => { for ( let i = 0 ; i < 10 ; i++) { state.list.push({ full_photo : "http://static.feidaojixie.com/machine/51271/full_photo/e9532332299e357ab815373a145f8ce2" , concact_name : "Contact" + (state.list.length + 1 ), collection_time : "2021-3-3 15:23 " , }); } //End of loading state state.showLoading = false ; //All data is loaded if (state.list.length >= 40 ) { state.finished = true ; } }, 3000 ); }; return { ...toRefs(state), emptyItem, routerView, requestData, }; }, }); </script> Copy code
  • API
    • Props
parameterDescriptionTypes ofDefaults
list-dataData arrayArray[]
bind-keyThe key bound by vue's for loopString,FunctionThe default value is index
v-model:loadingWhether it is in the loading state, the load event is not triggered during the loading processBooleanfalse
errorWhether the loading fails, click the error prompt after loading fails
To re-trigger the load eventbooleanfalse
finishedWhether the loading has been completed, the load event will not be triggered after the loading is completeBooleanfalse
loading-textPrompt copy during loadingStringLoading...
finished-textPrompt copy after loadingStringNo more...
error-textPrompt copy after loading failedStringLoading failed, click me to reload
empty-textPrompt copy when the data is emptyStringNo data
immediate-checkWhether to perform scroll position check immediately upon initializationBooleantrue
empty-itemSet the data template used by the skeleton screen, mainly used to open element tagsObject{}
bind-scroll-documentThe sliding container where the list is located, the default is windowObjectwindow
  • Events
Event nameDescriptionCallback parameter
loadTriggered when the distance between the scroll bar and the bottom is less than offset-
  • Slots
nameDescription
defaultList content
loadingCustomize the bottom loading prompt
finishedCustomize the bottom loading completion prompt
errorCustomize the bottom loading failure prompt
emptyCustom list data is empty prompt

problem

  • What is the realization of the skeleton screen? The skeleton screen is implemented by setting the background color of the img, span, and a tags in the child item through the CSS style, so you need to pass the empty-item parameter to prop up the span and a tags of the list element. If you also use other tags, You can refer to the css style in the source code to add other tags

  • What is the operating mechanism of List? List will listen to the scroll event of the browser or target element and calculate the position of the list. When the distance between the bottom of the list and the visible area is less than the offset, the List will trigger a load event.

  • What is the meaning of loading and finished? List has the following five states. Understanding these states will help you use List components correctly:

    1. init, initial loading, when loading is true and the length of listData is 0, it is in init state, and the skeleton screen is displayed at this time
    2. During non-loading, loading is false, at this time, it will judge whether to trigger the load event according to the scroll position of the list
    3. During loading, loading is true, indicating that an asynchronous request is being sent, and the load event will not be triggered at this time
    4. The loading is complete, finished is true and the length of listData is not 0, the load event will not be triggered at this time
    5. No data yet, finished is true, loading is false, finished is true

    After each request is completed, you need to manually set loading to false to indicate the end of loading

All codes

If you feel that copying the code is too troublesome, you can use my template directly, which contains all the code, we will talk about this template next time

//Build with yarn vue create --preset direct:https://gitee.com/wqja/vue3_ts_preset.git --clone my-project Copy code
  • ListView.tsx
Import {computed, defineComponent, nextTick, onMounted, OnUpdated, PropType, REF, Watch} from 'VUE' ; Import './list_view.css' Import './skeleton.css' //The tools of vant are directly used here import {useRect, useScrollParent, useEventListener} from '@/use' ; import {isHidden} from "@/util/Utils" ; type ViewStatusType = 'INIT' | 'SHOW' | 'ERROR' | 'FINISHED' | 'EMPTY' ; export default defineComponent({ name : 'ListViewTSX' , props :{ listData : { type : Array , default : () => { return []; }, }, bindKey :{ type : [ String , Function ], default : () => null }, loading : { type : Boolean , default : () => false , }, error : { type : Boolean , default : () => false , }, finished : { type : Boolean , default : () => false , }, emptyText : { type : String , default : () => { return "No data yet" ; }, }, emptyItem : { type : Object , default : () => { return {}; } }, placeCount :{ type : Number , default : () => { return 10 ; } }, loadingText : { type : String , default : () => "Loading..." , }, finishedText : { type : String , default : () => "No more" , }, errorText : { type : String , default : () => "Failed to load, click me to reload" , }, offset : { type : Number , default : () => 100 , }, immediateCheck : { type : Boolean , default : () => true , }, direction : { type : String as PropType< 'up' | 'down' >, default : 'down' , }, noPackage :{ type : Boolean , default : ()=> { return false ; } } }, emits : [ 'load' , 'update:error' , 'update:loading' ], setup ( props,{ emit, slots} ) { const loading = ref( false ); const root = ref<HTMLElement>(); const placeholder = ref<HTMLElement>(); const scrollParent = useScrollParent(root); const check = () => { nextTick( () => { if (loading.value || props.finished || props.error) { return ; } const {offset,direction} = props; const scrollParentRect = useRect(scrollParent); if (!scrollParentRect.height || isHidden(root)) { return false ; } let isReachEdge = false ; const placeholderRect = useRect(placeholder); if (direction === 'up' ) { isReachEdge = scrollParentRect.top-placeholderRect.top <= offset; } else { isReachEdge = placeholderRect.bottom-scrollParentRect.bottom <= offset; } if (isReachEdge) { loading.value = true ; emit( 'update:loading' , true ); emit( 'load' ); } }); }; watch([ () => props.loading, () => props.finished], check); onUpdated( () => { loading.value = props.loading!; }); onMounted( () => { if (props.immediateCheck) { check(); } }); useEventListener( 'scroll' , check, { target : scrollParent }); const clickErrorText = () => { emit( 'update:error' , false ); check(); }; const viewStatus = computed<ViewStatusType>((): ViewStatusType => { if (props.listData.length === 0 && props.loading) { return "INIT" ; } if (props.listData.length === 0 && props.finished) { return "EMPTY" ; } if (props.error) { return "ERROR" ; } if (props.finished) { return "FINISHED" ; } return "SHOW" ; }); const listData = computed( ()=> { if (viewStatus.value=== 'INIT' ){ const emptyArr = []; const count = props.placeCount> 10 ? 10 :props.placeCount; for ( let i = 0 ;i<count;i++){ emptyArr.push(props.emptyItem); } return emptyArr; } else if (viewStatus.value === 'EMPTY' ){ return [] } return props.listData; }) const itemKeyFun = (item,index): string => { if (viewStatus.value=== 'INIT' ){ return index; } if (!props.bindKey){ return index; } else { if (props.bindKey instanceof Function ){ return props.bindKey(item,index); } else { return item[props.bindKey] } } } const contentList = () => { if (props.noPackage){ return listData.value.map( ( e,index )=> { return slots.default?.({ item :e, index :index, vClass :viewStatus. value=== 'INIT' ? 'skeleton-view-empty-view' : 'skeleton-view-default-view' }) }) } else { return listData.value.map( ( e,index )=> { return ( < div key = {itemKeyFun(e,index)} class = {viewStatus.value === 'INIT' ?' skeleton-view- empty-view ' : ' skeleton-view-default-view '}> { slots.default?.({ item:e, index:index })} </div > ) }) } } const renderFinishedText = () => { if (viewStatus.value === 'FINISHED' ) { const text = slots.finished? slots.finished(): props.finishedText; if (text) { return < div class = 'list -view-center' > {text} </div > ; } } }; const renderErrorText = () => { if (viewStatus.value === 'ERROR' ) { const text = slots.error? slots.error(): props.errorText; if (text) { return ( < div class = ' list-view-center' onClick = {clickErrorText} > {text} </div > ); } } }; const renderLoading = () => { if (viewStatus.value === 'SHOW' ) { return ( < div class = 'list-view-center' > {slots.loading? ( slots.loading() ): ( < div class = 'list-view-center' onClick = {clickErrorText} > Loading... </div > )} </div > ); } }; return () => { const Content = contentList(); const Placeholder = < div ref = {placeholder} /> ; return ( < div ref = {root} role = "feed" > {props.direction ==='down'? Content: Placeholder} {renderLoading()} {renderFinishedText()} {renderErrorText()} {props.direction ==='up'? Content: Placeholder} </div > ); } } }) Copy code
  • list_view.css
.list-view-center { display : flex; flex-direction : row; align-items : center; justify-content : center; padding : 20px ; color : #777 ; font-size : 15px ; } Copy code
  • skeleton.css is the same as CalmView. For details, please click here to view
.skeleton-view-default-view span , .skeleton-view-default-view a , .skeleton-view-default-view img { transition : all. 7s ease; background-color : rgba ( 0 , 0 , 0 , 0 ) } .skeleton-view-empty-view { pointer-events : none; } .skeleton-view-empty-view span , .skeleton-view-empty-view a { color : rgba ( 0 , 0 , 0 , 0 ) !important ; border-radius : 2px ; background : linear-gradient ( - 45deg , # f5f5f5 0% , #DCDCDC 25% , # f5f5f5 50% , #DCDCDC 75% , # f5f5f5 100% ); animation : gradientBG 4s ease infinite; background-size : 400% 400% ; background-color : #DCDCDC ; transition : all 1s ease; } /* [src=""],img:not([src])*/ .skeleton-view-empty-view img { content : url ( ../../assets/img/no_url.png );//A blank picture, you can replace border-radius : 2px ; background : linear-gradient ( - 45deg , # f5f5f5 0% , #DCDCDC 25% , # f5f5f5 50% , #DCDCDC 75% , # f5f5f5 100% ); animation : gradientBG 4s ease infinite; background-size : 400% 400% ; background-color : #DCDCDC ; transition : all 1s ease; } @keyframes gradientBG { 0% { background-position : 100% 100% ; } 50% { background-position : 0% 0% ; } 100% { background-position : 100% 100% ; } } Copy code

Related tools (all are the tools in the copied vant, if there is any infringement, please contact me to delete)

  • isHidden
Import {the UNREF, Ref} from 'VUE' ; Export function the isHidden ( elementRef: the HTMLElement | Ref <the HTMLElement | undefined > ) { const EL = the UNREF (elementRef); IF (EL!) { return to false ; } const style = window .getComputedStyle(el); const hidden = style.display === 'none' ; //offsetParent returns null in the following situations: //1. The element or its parent element has the display property set to none. //2. The element has the position property set to fixed const parentHidden = el.offsetParent === null && style.position !== 'fixed' ; return hidden || parentHidden; } Copy code
  • useRect
Import {Ref,} the UNREF from 'VUE' ; function IsWindow ( Val: Unknown ): Val IS the Window { return Val === window ; } export const useRect = ( elementRef: (Element | Window) | Ref<Element | Window | undefined > ) => { const element = unref(elementRef); if (isWindow(element)) { const width = element.innerWidth; const height = element.innerHeight; return { top : 0 , left : 0 , right : width, bottom : height, width, height, }; } if (element && element.getBoundingClientRect) { return element.getBoundingClientRect(); } return { top : 0 , left : 0 , right : 0 , bottom : 0 , width : 0 , height : 0 , }; }; Copy code
  • useEventListener
Import {Ref, the UNREF, onUnmounted, onDeactivated} from 'VUE' ; Import {onMountedOrActivated} from '../onMountedOrActivated' ; const inBrowser = typeof window !== 'undefined' ; let supportsPassive = false ; if (inBrowser) { try { const opts = {}; Object .defineProperty(opts, 'passive' , { get () { supportsPassive = true ; }, }); window .addEventListener( 'test-passive' , null as any , opts); //eslint-disable-next-line no-empty } catch (e) {} } export type UseEventListenerOptions = { target?: EventTarget | Ref<EventTarget | undefined >; capture?: boolean ; passive?: boolean ; }; export function useEventListener ( type : string , listener: EventListener, options: UseEventListenerOptions = {} ) { if (!inBrowser) { return ; } const {target = window , passive = false , capture = false } = options; let attached: boolean ; const add = () => { const element = unref(target); if (element && !attached) { element.addEventListener( type , listener, supportsPassive? {capture, passive}: capture ); attached = true ; } }; const remove = () => { const element = unref(target); if (element && attached) { element.removeEventListener( type , listener, capture); attached = false ; } }; onUnmounted(remove); onDeactivated(remove); onMountedOrActivated(add); } Copy code
  • useScrollParent
Import {REF, Ref, onMounted} from 'VUE' ; type ScrollElement = HTMLElement | Window; const overflowScrollReg = /scroll|auto/i ; function isElement ( node: Element ) { const ELEMENT_NODE_TYPE = 1 ; return ( node.tagName !== 'HTML' && node.tagName !== 'BODY' && node.nodeType === ELEMENT_NODE_TYPE ); } //https://github.com/youzan/vant/issues/3823 function getScrollParent ( el: Element, root: ScrollElement = window ) { let node = el; while (node && node !== root && isElement(node)) { const {overflowY} = window .getComputedStyle(node); if (overflowScrollReg.test(overflowY)) { return node; } node = node.parentNode as Element; } return root; } export function useScrollParent ( el: Ref<Element | undefined > ) { const scrollParent = ref<Element | Window>(); onMounted( () => { if (el.value) { scrollParent.value = getScrollParent(el.value); } }); return scrollParent; } Copy code
  • onMountedOrActivated
Import {nextTick, onMounted, OnActivated} from 'VUE' ; export function onMountedOrActivated ( hook: () => any ) { let mounted: boolean ; onMounted( () => { hook(); nextTick( () => { mounted = true ; }); }); onActivated( () => { if (mounted) { hook(); } }); } Copy code