Virtual List с элементами с заранее неизвестной высотой

В данном посте я расскажу, как сделать Virtual List с элементами заранее неизвестной высоты. Источник: Virtual list with dynamic elements

У нас есть массив items, где у каждого элемента может быть задана высота (но изначально ее нет, так как она будет известна только отображения данных).

Во-первых: функция высоты

В первый раз мы не знаем высоту, поэтому используем какое-то примерное число, например, 100.

height : function(item) {
   return typeof item.height === 'undefined' ? 100 : item.height;
}

Во-вторых: метод вычисления высоты

updateVl() {
   //Проходимся по всем элементам списка, например это Card.
   this.$el.find('.card').each((index, el) => {
      const height = this.$(el).height(); //Вычисляем высоту
      const id = this.$(el).data('id'); //Берем ID, чтобы найти нужный элемент в items
      this.items.find(c => c.id == id).height = height; //Записываем настоящую высоту
   });
}

Вот пример элемента:

<div class="card" style="position:relative;" data-id="...">
...
</div>

position:relative; - обязательно для элемента списка Virtual List
data-id - для сопоставления DOM-элемента и элемента в items.

Метод updateVl нужно вызывать при создании / изменении Virtual List.

Почти все, осталось только…

В-третьих: нужно реагировать на изменение размеров окна

pageInit() {
   this.$app.on('resize', this.updateVl); //Подписались
},
pageBeforeRemove() {
   this.$app.off('resize', this.updateVl); //Отписались
}
4 Likes

Я использую Vue. Вызываю данный метод в:

renderExternal(vl, vlData) {
         this.updateVl();
         this.vlData = vlData;
}

Правильно понимаю где то нужно ещё запускать метод virtualList.update() или не надо?

update используется для того, что пересчитать логику списка, ведь высота теперь известна. Как это работает в связке с Vue я не знаю

Владимир, у меня реализовано так:

<template>
...
<f7-list virtual-list
  ref="f7VList"
  @virtual:itembeforeinsert="VListEvent"
  @virtual:itemsbeforeinsert="VListEvent"
  @virtual:itemsafterinsert="VListEvent"
  @virtual:beforeclear="VListEvent"
  :virtual-list-params="{items: items, renderExternal: renderExternal, height: Height}"> <!-- указываю в параметрах ссылку на метод Height -->
   <f7-list-item v-for="(Item, Index) in List"
     :key="Item.id"
     class="card"
     media-item
     :data-id="Item.id"
     :style="`top: ${this.vlData.topPosition}px;`"
     ref="List">
     //=> item's dynamic content
   </f7-list-item>
</f7-list>
...
</template>
//script
 data() {
   return {
       itemHeights: {}
   }
 },
 methods: {
   updateVl($where) {
      for (let Index in this.$refs.List) {
         let {dataset: {id}, clientHeight} = this.$refs.List[Index].$el;
         this.itemHeights[id] = clientHeight;   //заполняю объект с Идентификаторами и значениями высоты
      }
   },
   renderExternal(vl, vlData) {
      this.updateVl();
      this.vlData = vlData;
   },
   Height($Item, $Rest) {
     const {id} = $Item;
     return this.itemHeights[id] || 16 + 90; //если есть готовые данные даем, нет по умолчанию 
   },
   VListEvent($Event) {
     console.log('VListEvent:', $Event);
   }
 }

Где нужно вызывать? И как поймать события в f7-list @virtual:* ?

Что вызывать? Не нужно вызывать .update().

Никак, при renderExternal они не вызываются так как рендеринг идет средствами Vue

Вызываю метод updateVl() вычислил все текущие актуальные высоты. Потом нужно заново отрендерит, как это сделать, каким методом?

Вот делал по данной ссылке - https://forum.framework7.io/t/virtual-list-with-dynamic-elements/3991/2

Blockquote
The best way of course is height function where you need to return element height. But if you don’t know it then it is a bit tricky. Then I suggest logic should be the following: after VL elements inserted you get the DOM element height and save it in some cache object/array, and trigger VL to rerender list, and inside of its height function you return that value from cache

Как затрегерить повторный рендеринг во Vue?

Как затрегерить повторный рендеринг во Vue?

this.$forceUpdate() или попробовать метод .update() виртуального списка тогда

Кроме этих, еще попробовал другие варианты, в итоге что то получилось.

Работающие варианты:

Эти не работают:

  • this.$f7.virtualList.get().clearCache();
  • this.$forceUpdate(); //не работает

Во всех случаях, после обновления есть мерцание, а на телефоне(Android, средний Xiaomi-RedmiNote8Pro) когда быстро прокручиваешь, navbar остается на месте(и цвет не меняется), а где content чернота, иногда видна главная страница.

Вопросы:

  • Какой из выше указанных(работающих) методов более правильный?
  • Может еще предложите другие варианты?
  • Может для виртуального листа сделать новый метод обновления высоты?

Почему это важно:

  • У меня есть списки документов, где есть куча атрибутов(доходят до 15шт.), которые нужно показать и они всегда меняются, у кого что. Не возможно предугадать заранее высоту элемента списка. В данный момент решаю, показывают в виде таблицы.

Внизу сделал пример, где есть код. Через правый верхний угол можно(нужно выбрать) метод повторного рендеринга.

Код:

<template>
   <f7-page name="virtual-list"
            :page-content="false">
      <f7-navbar>
         <f7-nav-title>
            F7 Virtual List <span :class="processColor">[{{ renderType || 'none'}}]</span>
            <span class="subtitle">
               Dynamic Height
               [
               key:{{ f7ListKey }}
               /
               items:{{ items.length }}
               ]
            </span>
         </f7-nav-title>
         <f7-nav-right>
            <!--            <f7-link @click="ScrollStop" v-if="scrolling">-->
            <!--               [scrollStop]-->
            <!--            </f7-link>-->
            <!--            <f7-link @click="ScrollStart" v-else>-->
            <!--               [scrollStart]-->
            <!--            </f7-link>-->
            <f7-link @click="$refs.settings.open()">
               <f7-icon f7="gear_alt"/>
               <br/>
               <div style="font-size: 10px;   transform: rotate(-90deg);">
                  v4
               </div>
            </f7-link>
         </f7-nav-right>
      </f7-navbar>

      <f7-sheet ref="settings"
                style="height:auto; --f7-sheet-bg-color: #fff;"
                backdrop>
         <f7-block-title medium class="margin-top">
            Rendering method:
         </f7-block-title>
         <f7-list>
            <f7-list-item radio title="None"
                          name="type"
                          :checked="renderType===null"
                          @change="Type_change(null)"/>
            <f7-list-item radio title="f7.virtualList.clearCache()"
                          name="type"
                          :checked="renderType==='VlClearCache'"
                          @change="Type_change('VlClearCache')"/>
            <f7-list-item radio title="f7.virtualList.update()"
                          name="type"
                          :checked="renderType==='VlUpdate'"
                          @change="Type_change('VlUpdate')"/>
            <f7-list-item radio title="f7.virtualList.replaceAllItems()"
                          name="type"
                          :checked="renderType==='VlReplaceAllItems'"
                          @change="Type_change('VlReplaceAllItems')"/>
            <f7-list-item radio title="Vue.$forceUpdate()"
                          name="type"
                          :checked="renderType==='VueForceUpdate'"
                          @change="Type_change('VueForceUpdate')"/>
            <f7-list-item radio title="Vue.key"
                          name="type"
                          :checked="renderType==='VueKey'"
                          @change="Type_change('VueKey')"/>
         </f7-list>
      </f7-sheet>

      <f7-page-content :infinite="infiniteScroll"
                       id="Page"
                       ref="F7List"
                       :infinite-distance="infiniteDistance"
                       :infinite-preloader="false"
                       @infinite="onInfinite">
         <f7-block v-if="visible && !items.length">
            Loading..
         </f7-block>
         <f7-list v-else-if="visible && items.length"
                  virtual-list
                  :virtual-list-params="f7VirtualListParams"

                  :key="f7ListKey"

                  class="no-margin-vertical"
                  media-list
                  no-hairlines
                  no-hairlines-between
                  no-chevron>
            <ul>
               <f7-list-item
                       v-for="(item, index) in vlData.items"
                       :key="index"
                       media-item
                       link="#"
                       :data-id="item.id"
                       ref="List"
                       class="card"
                       :title="item.title"
                       :subtitle="item.subtitle"
                       :text="item.text"
                       :after="(heights[item.id] || '~')+'px'"
                       :style="`top: ${vlData.topPosition}px`">
                  <div slot="media" style="width: 80px;">
                     <img :src="item.image"
                          style="min-width: 80px !important; height: 80px !important;">
                  </div>
                  <div slot="root-end" v-if="item.footer" class="padding-horizontal">
                     {{ item.footer }}
                  </div>
               </f7-list-item>
            </ul>
         </f7-list>
         <f7-block v-else>
            Reloading..
         </f7-block>
      </f7-page-content>
   </f7-page>
</template>

<script>
   const SomeText = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`
   const ImageApi = 'https://i.picsum.photos/id/';
   const SomeImages = [1022, 103, 1039, 1043, 1041, 1062, 1069, 110, 161, 164];
   const SomeColors = ['blue', 'red', 'orange', 'green', 'pink', 'lime'];
   //number of items
   const ItemsMax = 1000;
   const ItemsPerRequest = 30;

   let checker = 0;

   function randomNumberBetween($Min, $Max) {
      return Math.floor(Math.random() * $Max) + $Min;
   }

   export default {
      data() {
         return {
            processColor: '',
            visible: true,
            renderType: null,
            f7ListKey: 0,
            heights: {},
            start: 1,
            items: [],
            vlData: {},
            infiniteScroll: true,
            infiniteAllow: true,
            infiniteDistance: 600,
            scrolling: false
         };
      },
      computed: {
         f7VirtualListParams() {
            return {
               items: this.items,
               rowsBefore: 4,
               rowsAfter: 4,
               renderExternal: this.renderExternal,
               height: this.Height,
            };
         },
      },
      methods: {
         fakeApi($Start) {
            // console.warn('fakeApi:', $Start)
            let id = $Start;
            setTimeout(() => {
               for (let i = 1; i <= ItemsPerRequest; i++) {
                  let randomTitle = SomeText.slice(randomNumberBetween(20, 60), randomNumberBetween(61, 200));
                  let randomText = SomeText.slice(0, randomNumberBetween(0, 100));
                  this.items.push({
                     id: id,
                     title: id + ' ' + randomTitle,
                     subtitle: (randomNumberBetween(1, 100) % 2) ? `Subtitle ${id}` : null,
                     text: randomText,
                     image: this.Image(id),
                     color: this.Color(id),
                     footer: (randomNumberBetween(1, 100) % 3 === 1) ? `:-)` : null,
                     height: 73
                  });
                  ++id;
               }
               this.start = id;
               this.f7ListKey += 1;
               this.infiniteAllow = true;
            }, randomNumberBetween(10, 300));
         },
         Image($ID) {
            const PixelRatio = this.$f7.device.pixelRatio || 1;
            const Width = 80 * PixelRatio;
            const Height = 80 * PixelRatio;
            return ImageApi + SomeImages[$ID % 10] + '/' + Width + '/' + Height + '.jpg'
         },
         Color($ID) {
            return SomeColors[$ID % 6];
         },
         Height($Item) {
            return this.heights[$Item.id] || 73;
         },
         renderExternal(vl, vlData) {
            this.vlData = vlData;
            this.updateVl();
         },
         ProcessColor() {
            this.processColor = 'text-color-yellow';
            setTimeout(() => {
               this.processColor = '';
            }, 500);
         },
         updateVl() {
            console.warn('UPDATEVL')
            ++checker;
            if (this.$refs.List) {
               this.ProcessColor();
               for (let Index in this.$refs.List) {
                  let item = this.$refs.List[Index];
                  let {
                     dataset: {id}, //id of item
                     clientHeight,  //height without scrollBar, border, and the margin.
                     offsetHeight   //height wihtout margin
                  } = item.$el;
                  let {marginTop, marginBottom} = window.getComputedStyle(item.$el);   //in pixels
                  //calculating on fly,
                  const MarginTop = parseInt(marginTop) / 2;
                  const MarginBottom = parseInt(marginBottom) / 2;
                  // console.warn('clientHeight:', clientHeight,
                  //    'offsetHeight:', offsetHeight,
                  //    'marginTop:', marginTop,
                  //    'marginBottom:', marginBottom)
                  this.heights[id] = clientHeight + MarginTop + MarginBottom;
               }
               switch (this.renderType) {
                  case 'VlClearCache':
                     this.$nextTick(() => {
                        if (checker % 2 === 0) {
                           let timeStart = +new Date();
                           this.$f7.virtualList.get().clearCache();
                           console.log('UpdateVl VlClearCache:', +new Date() - timeStart)
                        }
                     });
                     break;
                  case 'VlUpdate':
                     this.$nextTick(() => {
                        if (checker % 2 === 0) {
                           let timeStart = +new Date();
                           this.$f7.virtualList.get().update();
                           console.log('UpdateVl VlUpdate:', +new Date() - timeStart)
                        }
                     });
                     break;
                  case 'VlReplaceAllItems':
                     this.$nextTick(() => {
                        if (checker % 2 === 0) {
                           let timeStart = +new Date();
                           this.$f7.virtualList.get().replaceAllItems(this.items);
                           console.log('UpdateVl VlReplaceAllItems:', +new Date() - timeStart)
                        }
                     });
                     break;
                  case 'VueForceUpdate':
                     let timeStart = +new Date();
                     this.$forceUpdate();
                     console.log('UpdateVl VueForceUpdate:', +new Date() - timeStart)
                     break;
                  case 'VueKey':
                     if (checker % 2 === 0) {
                        this.f7ListKey += 1;
                        console.log('UpdateVl VueKey')
                     }
                     break;
                  default:
                     break;
               }
            }
         },
         Type_change($Type) {
            this.visible = false;
            this.renderType = $Type;
            setTimeout(() => {
               this.start = 1;
               this.items = [];
               this.vlData = {};
               this.heights = {};
               this.f7ListKey = 0;
               this.visible = true;
               this.fakeApi(this.start);
            }, 500)
         },
         onInfinite() {
            console.warn('onInfinite()')
            if (this.infiniteAllow) {
               this.infiniteAllow = false;
               this.fakeApi(this.start);
            }
         },
         ScrollStart($Event, $Height = 0) {
            if (!this.scrolling) {
               this.scrolling = true;
               let listContainer = this.$$("#Page")
               // console.log('LC:', listContainer.scrollHeight)
               // console.log('LC:', listContainer.scrollTop())
               // this.scrolling = setInterval(() => {
               $Height += 400;
               listContainer.scrollTop($Height, 5000, () => {
                  this.scrolling = false;
                  this.ScrollStart(null, $Height);
               });
               // console.log('scrolling:', $Height)
               // }, 8000);
            }
         },
         ScrollStop() {
            // clearInterval(this.scrolling);
            this.scrolling = true;
         }
      },
      mounted() {
         this.$nextTick(() => {
            this.updateVl('nextTick');
         });
      },
      created() {
         this.fakeApi(this.start);
      }
   };
</script>

<style scoped>
   .item-media {
      min-width: 80px !important;
   }
</style>

Thanks Shastox, this is a very helpful example.

Just want to add some things in case others get stuck, in my case I needed to use outerHeight(true); to get the height + margin which I think most people will need.

Also - the default height of 100 may cause jumpiness when re-rendering depending on what your content is. In my case the simplest solution was to set it to 0 until the real height is determined, then scroll is smooth.