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 sonunda şu şekilde görünür:

Üç kahve resminin gösterildiği tamamlanmış bir hikaye görüntüleyici bileşeni

Sosyal medya veya haber "hikayesini", slayt gösterisi gibi sırayla oynatılacak bir kart koleksiyonu olarak düşünebiliriz. Aslında hikayeler kelimenin tam anlamıyla slayt gösterileridir. Kartlarda genellikle bir resim veya otomatik oynatılan video bulunur ve üstte ek metin bulunabilir. Aşağıda oluşturacağımız uygulamayı görebilirsiniz:

Özellik Listesi

  • Resim veya video arka planı içeren kartlar.
  • Hikayede gezinmek için ekranı sola veya sağa kaydırın.
  • Otomatik oynatılan videolar
  • Kartlara metin ekleme veya 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 deposu klonlayarak başlayın: story-viewer-starter

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

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

npm i

VS Code kullanıcıları, lit-html şablonlarında otomatik tamamlama, tür denetimi ve linting için lit-plugin uzantısını yüklemelidir.

Aşağıdaki komutu çalıştırarak geliştirme ortamını başlatın:

npm run dev

Kodlamaya hazırsınız.

3. <story-card> Bileşeni

Bileşik bileşenler oluştururken bazen daha basit alt bileşenlerle başlayıp bunları geliştirmek 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 LitElement sınıfını genişleten sınıfını tanımlamaktır. customElement tasarımcısı, özel öğeyi kaydetme işlemini bizim için halleder. experimentalDecorators işaretini kullanarak tsconfig dosyanızda süslemeleri etkinleştirdiğinizden emin olmanın tam zamanı (Başlangıç deposunu kullanıyorsanız süslemeler zaten etkindir).

Aşağıdaki kodu story-card.ts dosyasına ekleyin:

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

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

<story-card> artık kullanılabilir bir özel öğedir ancak henüz gösterilecek bir şey yoktur. Öğenin iç yapısını tanımlamak için render örneği 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 öğe sağlamalıdır: bir medya öğesi ve bir yer paylaşımı. Bu nedenle, bunların her biri için bir <slot> ekleyeceğiz.

Alanlar, bir özel öğenin alt öğelerinin oluşturulmasının gerektiğini belirtiriz. 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 slotuna ayırmak, tam sayfa stil ekleme ve videoları otomatik oynatma gibi işlemler için bu öğeyi hedeflememize yardımcı olur. Daha sonra varsayılan dolgu sağlayabilmemiz için ikinci yuvayı (özel yer paylaşımları için olan) bir kapsayıcı öğesinin içine koyun.

<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 ile CSS bu açıdan gerçekten çok kullanışlıdır.

Yuvalı medya öğesini <story-card> öğesini kapsayacak şekilde biçimlendirelim. Buradayken ikinci yuvada bulunan öğeler için güzel bir biçimlendirme yapabiliriz. Bu sayede, bileşen kullanıcıları bazı <h1> veya <p> öğeleri ekleyebilir ve varsayılan olarak güzel bir şey görebilir.

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şeni

<story-viewer> öğemiz, <story-card> öğelerinin üst öğesidir. Kartların yatay olarak düzenlenmesinden ve aralarında gezinmemize olanak tanımasından sorumludur. StoryCard kampanyasıyla aynı şekilde başlayacağız. <story-viewer> öğesinin alt öğesi olarak hikaye kartları eklemek istiyoruz. Bu nedenle, bu alt öğeler için bir yuva ekleyin.

Aşağıdaki kodu story-viewer.ts dosyasına ekleyin:

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ün mutlak konumları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%;
}

Şu anda görüntülenen kartı takip etmek için StoryViewer sınıfına bir örnek değişkeni index ekleyelim. LitElement'in @property özelliğiyle süslemek, değeri 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 konuma getirilmesi 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, slot için sorgu yapar ve slot.assignedElements() üzerinde döngü yaparı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 değerini 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 aynı hizada. 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 sayfalara gidebileceğimiz bir yapı oluşturalım.

5. İlerleme çubuğu ve gezinme

Ardından, kartlar arasında gezinme yöntemi ve ilerleme çubuğu ekleyeceğiz.

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

story-viewer.ts dosyasında, StoryViewer sınıfına şunları 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ının gezinmesine olanak tanımak için <story-viewer> öğesine "önceki" ve "sonraki" düğmelerini ekleyeceğiz. Düğmelerden biri tıklandığında, ya next ya da previous yardımcı işlevini çağırmak istiyoruz. lit-html, öğelere etkinlik işleyiciler eklemeyi kolaylaştırır; düğmeleri aynı anda oluşturup 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üğmelere stil uygulamak 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ğu için, her hikaye kartı için bir tane olmak üzere küçük kutulara stil uygulamak üzere CSS ızgarasına başvuracağız. Kutulara koşullu olarak sınıf ekleyerek "görünür olup olmadıklarını" belirtmek amacıyla index özelliğini kullanabiliriz. 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. Önce classMap dosyasını içe aktarın:

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

Ardından 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 stilde değişiklik yapalım.

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 kurucusu, etkinlik işleyicileri ana öğeye eklemek için mükemmel bir yerdir. Hammer kurucusu, hareketleri algılayacak bir öğe alır. Bizim örneğimizde bu, StoryViewer veya this olur. Ardından, Hammer'ın API'sini kullanarak "kaydırma" hareketini algılamasını ve kaydırma bilgilerini yeni bir _panData özelliğine ayarlamasını söyleriz.

LitElement, _panData mülkü @state ile süsleyerek _panData'teki değişiklikleri gözlemler ve güncelleme yapar ancak mülk için ilişkili bir HTML özelliği olmaz.

Ş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. İşlemleri kolaylaştırmak için static get styles'e geri dönelim ve ::slotted(*) seçicisine 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, 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, dizin her değiştiğinde uygun alt öğeler üzerinde "girdi" ve "çıktı" özel etkinliklerini göndererek uygularız. StoryCard'te bu etkinlikleri alır ve mevcut videoları oynatır veya duraklatırız. StoryCard'da tanımlanan "entered" (girdi) ve "exited" (çıktı) örnek yöntemlerini çağırmak yerine neden alt öğeler üzerindeki etkinlikleri göndermeyi tercih etmelisiniz? Yöntemler kullanıldığında, bileşen kullanıcıları özel animasyonlar içeren kendi hikaye kartlarını yazmak için özel bir öğe yazmak zorunda kalıyordu. 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, videoyu oynatıp duraklatan StoryCard oluşturucuda "entered" (girdi) ve "exited" (çıktı) için etkinlik işleyiciler ekleyeceğiz.

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. play işlevini bir img veya null üzerinde çağırmamaya 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 almak için bazı matematik işlemleri yapılır. Bu değer, etkin çocuk için 1.0'e, aksi takdirde minScale'a eşit olur ve bu iki değer arasında da ara değer bulunur.

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ış sürümünü görmek için https://github.com/PolymerLabs/story-viewer adresini ziyaret edin.