Aydınlatmalı Hikaye Bileşeni oluşturma

1. Giriş

Hikayeler günümüzde popüler bir kullanıcı arayüzü bileşeni. Sosyal uygulamalar ve haber uygulamaları, bunları feed'lerine entegre ediyor. Bu codelab'de lit-element ve TypeScript ile bir hikaye bileşeni oluşturacağız.

Hikaye bileşeni son olarak şöyle görünür:

Üç kahve resmi gösteren tamamlanmış bir hikaye görüntüleyici bileşeni

Sosyal medya veya haber "hikayeleri" aklımıza geliyor. bir slayt gösterisi gibi sırayla oynatılacak bir dizi karttan oluşur. Aslında hikayeler kelimenin tam anlamıyla slayt gösterileridir. Kartlara genellikle bir resim veya otomatik oynatılan video hakimdir ve kartlarda ek metin bulunabilir. Şunu oluşturacağız:

Özellik Listesi

  • Resim veya video arka planı içeren kartlar.
  • Hikayede gezinmek için sola veya sağa kaydırın.
  • Videoları otomatik oynatma.
  • Metin ekleme veya kartları başka bir şekilde özelleştirme olanağı.

Bu bileşenin geliştirici deneyimine kadar, hikaye kartlarını aşağıdaki gibi düz HTML işaretlemesinde belirtmek faydalı olacaktır:

<story-viewer>
  <story-card>
    <img slot="media" src="some/image.jpg" />
    <h1>Title</h1>
  </story-card>
  <story-card>
    <video slot="media" src="some/video.mp4" loop playsinline></video>
    <h1>Whatever</h1>
    <p>I want!</p>
  </story-card>
</story-viewer>

Bunu da özellik listesine ekleyelim.

Özellik Listesi

  • HTML işaretlemesinde bir dizi kartı kabul edin.

Bu şekilde, herkes hikaye bileşenimizi sadece HTML yazarak kullanabilir. Bu, programcılar ve programcı olmayanlar için mükemmeldir ve HTML'in yaptığı her yerde (içerik yönetim sistemleri, çerçeveler vb.) işe yarar.

Ön koşullar

  • git ve npm çalıştırabileceğiniz bir kabuk
  • Metin düzenleyici

2. Kurulum

Şu depoyu klonlayarak başlayın: story-viewer-starter

git clone git@github.com:PolymerLabs/story-viewer-starter.git

Ortam zaten lit-element ve TypeScript ile ayarlanmıştır. Bağımlılıkları yüklemeniz yeterlidir:

npm i

VS Code kullanıcıları otomatik tamamlama, tür kontrolü ve lit-html şablonlarında hata analizi özelliğinden yararlanmak için lit-plugin uzantısını yükleyebilirler.

Şu komutu çalıştırarak geliştirme ortamını başlatın:

npm run dev

Kodlamaya hazırsınız.

3. <story-card> Bileşen

Bileşik bileşenler oluştururken bazen daha basit alt bileşenlerle başlayıp bunu bir araya getirmek daha kolaydır. O zaman <story-card> oluşturarak başlayalım. Tam çerçeve bir videoyu veya bir resmi gösterebilmelidir. Kullanıcılar, örneğin yer paylaşımlı metin kullanarak bunu daha fazla özelleştirebilmelidir.

İlk adım, bileşenimizin sınıfını tanımlamaktır. Bu sınıf LitElement öğesini genişletir. customElement tasarımcısı, özel öğeyi kaydetme işlemini bizim için halleder. Şimdi iyi bir zaman, experimentalDecorators işaretiyle tsconfig'inizdeki tasarımcıları etkinleştirdiğinizden emin olmanın iyi bir zamanıdır (başlangıç deposunu kullanıyorsanız zaten etkinleştirilmiştir).

Aşağıdaki kodu story-card.ts içine yerleştirin:

import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('story-card')
export class StoryCard extends LitElement {
}

Artık <story-card> kullanılabilir bir özel öğe, ancak henüz gösterilecek bir şey yok. Öğenin dahili yapısını tanımlamak için render örnek yöntemini tanımlayın. Burada, lit-html'nin html etiketini kullanarak öğe için şablon sağlayacağız.

Bu bileşenin şablonunda ne bulunmalıdır? Kullanıcı iki şey sağlamalıdır: medya öğesi ve yer paylaşımı. Yani, bunların her biri için bir <slot> ekleriz.

Alanlar, özel bir öğenin alt öğelerinin oluşturulması gerektiğini belirtme şeklimizdir. Daha fazla bilgi için Slot kullanımıyla ilgili adım adım açıklamalı bir kılavuzu burada bulabilirsiniz.

import { html } from 'lit';

export class StoryCard extends LitElement {
  render() {
    return html`
      <div id="media">
        <slot name="media"></slot>
      </div>
      <div id="content">
        <slot></slot>
      </div>
    `;
  }
}

Medya öğesini kendi alanına ayırmak, tam çerçeve stil ve otomatik oynatılan videolar eklemek gibi öğeler için bu öğeyi hedeflememize yardımcı olur. İkinci alanı (özel yer paylaşımları için olan) bir kapsayıcı öğesinin içine yerleştirin. Böylece, daha sonra bazı varsayılan dolgular sağlayabiliriz.

<story-card> bileşeni artık şu şekilde kullanılabilir:

<story-card>
  <img slot="media" src="some/image.jpg" />
  <h1>My Title</h1>
  <p>my description</p>
</story-card>

Ama çok kötü görünüyor:

kahve resminin gösterildiği, özgün bir hikaye izleyicisi

Stil ekleniyor

Biraz stil ekleyelim. Lit-Element ile bunu statik bir styles özelliği tanımlayıp css ile etiketlenmiş bir şablon dizesi döndürerek yaparız. Burada yazılan her CSS yalnızca özel öğemiz için geçerlidir. Gölge DOM'si olan CSS bu açıdan çok kullanışlı.

Yuvarlanmış medya öğesini, <story-card> öğesini kapsayacak şekilde biçimlendirelim. Buradayken, ikinci aralıktaki öğeler için güzel bir biçimlendirme sağlayabiliriz. Bu şekilde, bileşen kullanıcıları birkaç <h1>, <p> veya başka bir şey kullanarak varsayılan olarak güzel bir şey görebilirler.

import { css } from 'lit';

export class StoryCard extends LitElement {
  static styles = css`
    #media {
      height: 100%;
    }
    #media ::slotted(*) {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }

    /* Default styles for content */
    #content {
      position: absolute;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
      padding: 48px;
      font-family: sans-serif;
      color: white;
      font-size: 24px;
    }
    #content > slot::slotted(*) {
      margin: 0;
    }
  `;
}

kahve resminin gösterildiği, hikaye izleyicisi tarzında bir izleyici

Artık arka plan medyası içeren hikaye kartlarımız var ve istediğimiz her şeyi en üste koyabiliyoruz. Güzel! Otomatik oynatılan videoları uygulamak için birazdan StoryCard sınıfına geri döneceğiz.

4. <story-viewer> Bileşen

<story-viewer> öğemiz, <story-card> öğelerinin üst öğesidir. Kartları yatay olarak yerleştirmek ve aralarında geçiş yapmamıza izin vermek sizin sorumluluğunuzdadır. StoryCard kampanyasıyla aynı şekilde başlayacağız. Hikaye kartlarını <story-viewer> öğesinin alt öğeleri olarak eklemek istiyoruz. Bu nedenle bu alt öğeler için bir yer ekleyin.

Story-viewer.ts bölümüne aşağıdaki kodu yerleştirin:

import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('story-viewer')
export class StoryViewer extends LitElement {
  render() {
    return html`<slot></slot>`;
  }
}

Sırada yatay düzen var. Bunu, yuvalı <story-card> öğelerinin tümünü mutlak konumlandırmalarını vererek ve bunları dizinlerine göre çevirerek gerçekleştirebiliriz. :host seçiciyi kullanarak <story-viewer> öğesinin kendisini hedefleyebiliriz.

static styles = css`
  :host {
    display: block;
    position: relative;
    /* Default size */
    width: 300px;
    height: 800px;
  }
  ::slotted(*) {
    position: absolute;
    width: 100%;
    height: 100%;
  }`;

Kullanıcı, yalnızca ana makinedeki varsayılan yüksekliği ve genişliği harici olarak geçersiz kılarak hikaye kartlarımızın boyutunu kontrol edebilir. Aşağıdaki gibi:

story-viewer {
  width: 400px;
  max-width: 100%;
  height: 80%;
}

Görüntülenmekte olan kartı takip etmek için StoryViewer sınıfına index örnek değişkeni ekleyelim. Bu öğenin LitElement'ın @property ile süslenmesi, değeri her değiştiğinde bileşenin yeniden oluşturulmasına neden olur.

import { property } from 'lit/decorators.js';

export class StoryViewer extends LitElement {
  @property({type: Number}) index: number = 0;
}

Her kartın yatay olarak uygun konuma çevrilmesi gerekir. Bu çevirileri Lit-Element'ın update yaşam döngüsü yönteminde uygulayalım. Bu bileşenin gözlemlenen bir özelliği her değiştiğinde güncelleme yöntemi çalışır. Genellikle alanı ve slot.assignedElements() üzerinden döngüyü sorgularız. Ancak, yalnızca bir tane adsız alanımız olduğu için bu, this.children kullanmakla aynıdır. Kolaylık olması için this.children operatörünü kullanalım.

import { PropertyValues } from 'lit';

export class StoryViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    const width = this.clientWidth;
    Array.from(this.children).forEach((el: Element, i) => {
      const x = (i - this.index) * width;
      (el as HTMLElement).style.transform = `translate3d(${x}px,0,0)`;
    });
    super.update(changedProperties);
  }
}

<story-card>'lerimiz artık yan yana. Uygun stile özen gösterdiğimiz sürece, alt öğe olarak diğer öğelerle birlikte çalışmaya devam ederler:

<story-viewer>
  <!-- A regular story-card child... -->
  <story-card>
    <video slot="media" src="some/video.mp4"></video>
    <h1>This video</h1>
    <p>is so cool.</p>
  </story-card>
  <!-- ...and other elements work too! -->
  <img style="object-fit: cover" src="some/img.png" />
</story-viewer>

build/index.html sayfasına gidip diğer hikaye kartı öğelerinin yorumlarını kaldırın. Şimdi bu bölümlere gidebilmemiz için yapalım.

5. İlerleme Çubuğu ve Gezinme

Ardından, kartlar arasında gezinmenin bir yolu ve bir ilerleme çubuğu ekleyeceğiz.

Hikayede gezinmek için StoryViewer öğesine bazı yardımcı işlevler ekleyelim. Dizini bizim için geçerli bir aralığa bağlarken dizini ayarlarlar.

story-viewer.ts'de StoryViewer sınıfına şunu ekleyin:

/** Advance to the next story card if possible **/
next() {
  this.index = Math.max(0, Math.min(this.children.length - 1, this.index + 1));
}

/** Go back to the previous story card if possible **/
previous() {
  this.index = Math.max(0, Math.min(this.children.length - 1, this.index - 1));
}

Son kullanıcıya gezinmeyi göstermek için "previous" ifadesini ekleyeceğiz ve "next" <story-viewer> düğmelerine dokunun. Düğmelerden biri tıklandığında, next veya previous yardımcı işlevini çağırmak istiyoruz. lit-html, öğelere etkinlik işleyiciler eklemeyi kolaylaştırır; düğmeleri görüntüleyebilir ve aynı anda bir tıklama işleyici ekleyebiliriz.

render yöntemini aşağıdaki şekilde güncelleyin:

export class StoryViewer extends LitElement {
  render() {
    return html`
      <slot></slot>

      <svg id="prev" viewBox="0 0 10 10" @click=${() => this.previous()}>
        <path d="M 6 2 L 4 5 L 6 8" stroke="#fff" fill="none" />
      </svg>
      <svg id="next" viewBox="0 0 10 10" @click=${() => this.next()}>
        <path d="M 4 2 L 6 5 L 4 8" stroke="#fff" fill="none" />
      </svg>
    `;
  }
}

Etkinlik işleyicileri render yönteminde, yeni svg düğmelerimize satır içi olarak nasıl ekleyebileceğimize göz atın. Bu, her etkinlik için kullanılabilir. Bir öğeye @eventname=${handler} formunun bağlamasını eklemeniz yeterlidir.

Düğmelerin stilini belirlemek için static styles özelliğine aşağıdakileri ekleyin:

svg {
  position: absolute;
  top: calc(50% - 25px);
  height: 50px;
  cursor: pointer;
}
#next {
  right: 0;
}

İlerleme çubuğunda her hikaye kartı için bir tane olmak üzere küçük kutuları biçimlendirmek üzere CSS ızgarasını kullanacağız. Kutulara koşullu olarak sınıf eklemek ve bu sınıfların "görünür" olup olmadığını belirtmek için index özelliğini kullanabiliriz. hakkında bilgi edindiniz. i <= this.index : 'watched': '' gibi bir koşullu ifade kullanabiliriz, ancak daha fazla sınıf eklersek ayrıntılar daha ayrıntılı hale gelebilir. Neyse ki, lit-html bu konuda yardımcı olmak için classMap adlı bir yönerge sağlar. İlk olarak classMap dosyasını içe aktarın:

import { classMap } from 'lit/directives/class-map';

Ayrıca render yönteminin alt kısmına aşağıdaki işaretlemeyi ekleyin:

<div id="progress">
  ${Array.from(this.children).map((_, i) => html`
    <div
      class=${classMap({watched: i <= this.index})}
      @click=${() => this.index = i}
    ></div>`
  )}
</div>

Ayrıca, kullanıcıların isterlerse doğrudan belirli bir hikaye kartına atlayabilmeleri için birkaç tıklama işleyici de ekledik.

static styles bölümüne eklenecek yeni stiller:

::slotted(*) {
  position: absolute;
  width: 100%;
  /* Changed this line! */
  height: calc(100% - 20px);
}

#progress {
  position: relative;
  top: calc(100% - 20px);
  height: 20px;
  width: 50%;
  margin: 0 auto;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  grid-gap: 10px;
  align-content: center;
}
#progress > div {
  background: grey;
  height: 4px;
  transition: background 0.3s linear;
  cursor: pointer;
}
#progress > div.watched {
  background: white;
}

Gezinme ve ilerleme çubuğu tamamlandı. Şimdi biraz tarz ekleyelim!

6. Kaydırma

Kaydırma işlemini uygulamak için Hammer.js hareket kontrol kitaplığını kullanacağız. Çekiç, tava gibi özel hareketleri algılar ve kullanabileceğimiz alakalı bilgiler (delta X gibi) içeren etkinlikleri gönderir.

Tavaları tespit etmek için Çekiç'i şu şekilde kullanabilir ve bir kaydırma etkinliği gerçekleştiğinde öğemizi otomatik olarak güncelleyebiliriz:

import { state } from 'lit/decorators.js';
import 'hammerjs';

export class StoryViewer extends LitElement {
  // Data emitted by Hammer.js
  @state() _panData: {isFinal?: boolean, deltaX?: number} = {};

  constructor() {
    super();
    this.index = 0;
    new Hammer(this).on('pan', (e: HammerInput) => this._panData = e);
  }
}

LitElement sınıfının oluşturucusu, barındırma öğesinin kendisine etkinlik işleyiciler eklemek için kullanabileceğiniz başka bir harika yerdir. Çekiç oluşturucu, hareketleri algılamak için bir öğe alır. Bizim örneğimizde bu, StoryViewer veya this olur. Ardından, Hammer'ın API'sini kullanarak "kaydırma" işlemini algılamasını hareketini yapın ve kaydırma bilgilerini yeni bir _panData özelliğine ayarlayın.

_panData mülkünü @state ile süsleyerek LitElement, _panData ile ilgili değişiklikleri gözlemleyip güncelleme gerçekleştirir ancak mülk için ilişkili bir HTML özelliği bulunmaz.

Şimdi, kaydırma verilerini kullanmak için update mantığını geliştirelim:

// Update is called whenever an observed property changes.
update(changedProperties: PropertyValues) {
  // deltaX is the distance of the current pan gesture.
  // isFinal is whether the pan gesture is ending.
  let { deltaX = 0, isFinal = false } = this._panData;
  // When the pan gesture finishes, navigate.
  if (!changedProperties.has('index') && isFinal) {
    deltaX > 0 ? this.previous() : this.next();
  }
  // We don't want any deltaX when releasing a pan.
  deltaX = isFinal ? 0 : deltaX;
  const width = this.clientWidth;
  Array.from(this.children).forEach((el: Element, i) => {
    // Updated this line to utilize deltaX.
    const x = (i - this.index) * width + deltaX;
    (el as HTMLElement).style.transform = `translate3d(${x}px,0,0)`;
  });

  // Don't forget to call super!
  super.update(changedProperties);
}

Artık hikaye kartlarımızı ileri geri sürükleyebiliriz. Sürecin sorunsuz olması için static get styles bölümüne geri dönüp ::slotted(*) seçiciye transition: transform 0.35s ease-out; ekleyelim:

::slotted(*) {
  ...
  transition: transform 0.35s ease-out;
}

Şimdi sorunsuz kaydırma özelliğini kullanıyoruz:

Hikaye kartları arasında kolayca kaydırarak gezinin

7. Otomatik Oynatma

Ekleyeceğimiz son özellik ise videoların otomatik oynatılmasıdır. Odaklanılan hikaye kartı varsa arka plan videosunun oynatılmasını isteriz. Hikaye kartı odakta kaldığında videonun videosunu duraklatmamız gerekir.

Bunu, "girdi" komutunu belirterek uygularız ve "çıktı" özel etkinlikleri uygun alt öğeler aracılığıyla düzenler. StoryCard içinde bu etkinlikleri alıp mevcut videoları oynatacak veya duraklatacağız. Neden "Entered" komutu yerine alt öğeler üzerindeki etkinlikleri dağıtmayı tercih ediyorsunuz? ve "çıktı" StoryCard'da tanımlanan örnek yöntemleri var mı? Yöntemlerle bileşen kullanıcıları, özel animasyonlara sahip kendi hikaye kartlarını yazmak istediklerinde özel bir öğe yazmaktan başka bir seçenek bulamazlar. Etkinlikleri kullanarak yalnızca bir etkinlik işleyici ekleyebilirler.

StoryViewer öğesinin index özelliğini, etkinlikleri dağıtmak için kullanışlı bir kod yolu sağlayan bir belirleyici kullanacak şekilde yeniden düzenleyelim:

class StoryViewer extends LitElement {
  @state() private _index: number = 0
  get index() {
    return this._index
  }
  set index(value: number) {
    this.children[this._index].dispatchEvent(new CustomEvent('exited'));
    this.children[value].dispatchEvent(new CustomEvent('entered'));
    this._index = value;
  }
}

Otomatik oynatma özelliğini bitirmek üzere "girildi" için etkinlik işleyiciler ekleyeceğiz ve "çıktı" videoyu oynatıp duraklatan StoryCard oluşturucuda kullanabilirsiniz.

Bileşen kullanıcısının, <story-card> öğesine medya alanında bir video öğesi sağlayıp sağlamayacağını unutmayın. Medya alanına hiç öğe bile sağlamayabilirler. Bir resimdeki veya null'daki play çağrısını yapmamaya dikkat etmemiz gerekir.

Story-card.ts\'e geri dönüp aşağıdaki bilgileri ekleyin:

import { query } from 'lit/decorators.js';

class StoryCard extends LitElement {
  constructor() {
    super();

    this.addEventListener("entered", () => {
      if (this._slottedMedia) {
        this._slottedMedia.currentTime = 0;
        this._slottedMedia.play();
      }
    });

    this.addEventListener("exited", () => {
      if (this._slottedMedia) {
        this._slottedMedia.pause();
      }
    });
  }

 /**
  * The element in the "media" slot, ONLY if it is an
  * HTMLMediaElement, such as <video>.
  */
 private get _slottedMedia(): HTMLMediaElement|null {
   const el = this._mediaSlot && this._mediaSlot.assignedNodes()[0];
   return el instanceof HTMLMediaElement ? el : null;
 }

  /**
   * @query(selector) is shorthand for
   * this.renderRoot.querySelector(selector)
   */
  @query("slot[name=media]")
  private _mediaSlot!: HTMLSlotElement;
}

Otomatik oynatma tamamlandı. ✅

8. Terazileri Oynayın

Artık temel özelliklerin tümüne sahip olduğumuza göre, bir ekleme daha yapalım: hoş bir ölçeklendirme efekti. StoryViewer olan update yöntemine bir kez daha geri dönelim. scale sabitindeki değeri elde etmek için bazı matematik işlemleri yapılmalıdır. Etkin alt öğe için 1.0 değerine, aksi takdirde de bu iki değer arasında ara değer hesaplanarak minScale değerine eşit olur.

Story-viewer.ts'deki update yöntemindeki döngüyü şu şekilde değiştirin:

update(changedProperties: PropertyValues) {
  // ...
  const minScale = 0.8;
  Array.from(this.children).forEach((el: Element, i) => {
    const x = (i - this.index) * width + deltaX;

    // Piecewise scale(deltaX), looks like: __/\__
    const u = deltaX / width + (i - this.index);
    const v = -Math.abs(u * (1 - minScale)) + 1;
    const scale = Math.max(v, minScale);
    // Include the scale transform
    (el as HTMLElement).style.transform = `translate3d(${x}px,0,0) scale(${scale})`;
  });
  // ...
}

Hepsi bu kadar! Bu gönderide LitElement ve lit-html özellikleri, HTML alanı öğeleri ve hareket kontrolü gibi pek çok konuyu ele aldık.

Bu bileşenin tamamlanmış bir sürümü için https://github.com/PolymerLabs/story-viewer adresini ziyaret edin.