23 lip 2024
6 min

Przywracanie pozycji przewijania (scroll) w Angularze

Czy kiedykolwiek zdarzyło Ci się przewijać długą listę na stronie internetowej, na przykład taką prezentującą mnóstwo produktów? Znajdujesz coś interesującego, klikasz w to, aby dowiedzieć się więcej, a następnie decydujesz się na powrót do listy. Ciekawą funkcją, jaką posiadają niektóre strony internetowe, jest zapamiętywanie, gdzie byliśmy na liście, dzięki czemu możemy kontynuować od miejsca, w którym przerwaliśmy!

Aplikacja demonstracyjna

Rzućmy okiem na prosty przykład, aby pokazać, dlaczego jest to ważne. Wyobraź sobie małą aplikację, taką jak ta poniżej:

Pamiętaj, że lista ma więcej niż 9 produktów. Użytkownik może przewijać i zobaczyć więcej z nich.

W prawdziwym scenariuszu mielibyśmy serwis z wywołaniem HTTP do endpointa, który zwróciłby listę produktów. W naszym przypadku utworzymy serwis z zamockowanymi danymi.

products.service.ts

@Injectable({
  providedIn: 'root',
})
export class ProductsService {
  get() {
    const products = [...new Array(50)].map((it, index) => ({
      id: index + 1,
      name: `Product ${index + 1}`,
      price: 100,
      description: `This is product ${index + 1}`,
    }));
    return of(products);
  }
}

Potrzebujemy też komponentu, w którym będziemy wyświetlać produkty z tego serwisu.

products.component.ts

@Component({
  selector: 'app-products',
  standalone: true,
  imports: [
    NgFor, RouterLink, AsyncPipe, NgIf
  ],
  template: `
    <h2>Products</h2>
    @if (products$ | async; as products) {
      <ul class="products-container">
        @for (product of products; track product.id) {
          <li
            class="products-container--product-item"
            [routerLink]="['/products', product.id]"
          >
            <div>
              {{ product.name }}
            </div>
          </li>
        }
      </ul>
    }
  `,
  styles: [
    `
      .products-container {
        display: flex;
        gap: 16px;
        flex-wrap: wrap;

        &--product-item {
          list-style: none;
          width: 250px;
          height: 300px;
          border: 1px solid #ccc;
          display: flex;
          align-items: center;
          justify-content: center;
          cursor: pointer;
        }
      }
    `,
  ],
})
export class ProductsComponent {
  products$ = inject(ProductsService).get();
}

Definicja problemu

Jeśli uruchomisz tę aplikację i klikniesz produkt, przejdziesz do strony z jego szczegółami (products/:id). Po kliknięciu przycisku wstecz byłoby świetnie, gdyby poprzednia pozycja przewijania (scrolla) była przywrócona. 

Aby to osiągnąć, aktywujemy withInMemoryScrolling – funkcję routingu z wbudowaną obsługą przywracania pozycji przewijania.

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(
      routes,
      withInMemoryScrolling({
        scrollPositionRestoration: 'enabled',
      }),
    ),
  ],
};

Po włączeniu tej opcji, jeśli teraz przejdziesz do strony ze szczegółami produktu, a następnie wrócisz, pozycja przewijania zostanie przywrócona!

To wspaniale! A więc skończyliśmy? Tylko poniekąd. Jest problem, który musimy rozwiązać.

Jeśli przyjrzysz się bliżej products.services.ts przekonasz się, że zwracamy produkty bez żadnych opóźnień. Nie jest to jednak realny scenariusz, prawda? Zasymulujmy opóźnienie sieci w naszym serwisie, używając operatora delay z rxjs.

@Injectable({
  providedIn: 'root',
})
export class ProductsService {
  get() {
    const products = [...new Array(50)].map((it, index) => ({
      id: index + 1,
      name: `Product ${index + 1}`,
      price: 100,
      description: `This is product ${index + 1}`,
    }));
    return of(products).pipe(
      delay(3000), // Symulujemy opóźnienie sieci
    );
  }
}

Po wprowadzeniu sztucznego opóźnienia sieci zobaczymy, że kiedy wrócimy do listy produktów, nasza pozycja przewijania nie zostanie przywrócona. Zamiast tego jesteśmy na górze strony. Tak naprawdę próbujemy przewinąć do ostatnio zapamiętnej pozycji, ale wysokość strony nie jest jeszcze pełna. 

Zobaczmy przykład z pewnymi uproszczonymi liczbami; Wysokość naszego viewportu wynosi 800px, a maksymalna pozycja przewijania (przy 50 produktach w naszym przykładzie) to 2000. Przewijamy do pozycji 1000 i klikamy żądany produkt. Kiedy wracamy, próbujemy przywrócić ostatnio odwiedzoną pozycję (1000), ale ponieważ produktów jeszcze tam nie ma, maksymalna wysokość strony jest równa naszemu widocznemu obszarowi, czyli 800. To sprawia wrażenie, że przewijanie nie działa, ponieważ nie może jeszcze osiągnąć pozycji 1000.

Podejście do rozwiązania

Aby rozwiązać ten problem, musielibyśmy zachować ostatni stan pozycji przewijania, a następnie przewinąć do tej pozycji, gdy użytkownik wróci.

Zobaczmy, jak to zrobić:

  1. Aby zapisać pozycję przewijania, możemy użyć window.pageYOffset, która zwraca liczbę pikseli, o jaką dokument jest aktualnie przewinięty w pionie.
  2. Aby przewinąć do zapisanej pozycji, możemy skorzystać z metody window.scrollTo(x, y) który przewija do podanych współrzędnych.

Jeśli efektywnie wykorzystamy te dwa elementy, będziemy mieli rozwiązanie naszego problemu. Jednak utrzymanie ostatniej pozycji przewijania nie jest takie proste. Przynajmniej jeśli chcemy mieć rozwiązanie, które jest skalowalne i możliwe do ponownego wykorzystania na dowolnej liście. Musimy więc znaleźć sposób na utrzymanie ostatniej pozycji przewijania!

Kiedy próbowałem rozwiązać ten problem, mój CTO Ryana Hutchison naprowadził mnie na właściwą drogę, wspominając, że pozycja przewijania jest zawarta w zdarzeniu routingu. Zaraz, jak to? Tak, to prawda!

W rzeczywistości zdarzenie pozycji przewijania jest rejestrowane, gdy włączymy funkcję withInMemoryScrolling.

Przyjrzyj się bliżej, a znajdziesz pole nazwane „position”, która zawiera tablicę dwóch liczb. Pierwszy element tablicy reprezentuje oś X, a drugi oś Y.

Mając to na uwadze, nie musimy już zapisywać ostatniej pozycji przewijania. Wszystko, co musimy zrobić, to nasłuchiwać na zdarzenie Scroll i wywołać window.scroll(x,y).

Rozwiązanie

Zacznijmy od stworzenia strumienia, który „nasłuchuje” zdarzenia scroll:

inject(Router).events.pipe(
      filter((event): event is Scroll => event instanceof Scroll),
);

To zdarzenie ma różne pola: anchor, position, routerEvent, type. Potrzebujemy tylko position która jest krotką współrzędnych [x, y].

inject(Router).events.pipe(
      filter((event): event is Scroll => event instanceof Scroll),
      map((event: Scroll) => event.position),
 )

Musimy teraz przewinąć do tej pozycji. Na początku tego artykułu powiedzieliśmy, że użyjemy metody window.scrollTo. Cóż, zamiast tego użyjemy wrappera dostarczonego przez framework, ViewportScroller z paczki @angular/common, gdzie jego podstawowa implementacja opiera się na obiekcie okna. Sprawdź ten link dla kontekstu.

this.viewportScroller.scrollToPosition([x,y]);

Połączmy wszystko w jedną całość:

  constructor() {
    inject(Router)
      .events.pipe(
        filter((event): event is Scroll => event instanceof Scroll),
        map((event: Scroll) => event.position),
      )
      .subscribe((position) => {
        this.viewportScroller.scrollToPosition(position || [0, 0]);
      });
  }

To wygląda na rozwiązanie naszego problemu, ale najwyraźniej nadal nie działa. Dlaczego? Cóż, próbujemy przewinąć do position w niewłaściwym czasie. Jeśli pamiętasz, nasza serwis ma zasymulowane opóźnienie sieci. Musimy wziąć pod uwagę, że pracujemy z asynchronicznym zbiorem danych i spróbować przewijać, gdy nasz produkt jest renderowany na ekranie. Musimy więc znaleźć sposób, aby poczekać, aż nasze dane zostaną poprawnie wyrenderowane.

Możemy wprowadzić zmienną odniesienia do szablonu HTML i użyć signal queries, aby zapytać o ten element. Gdy tylko nasze zapytanie zwróci wartość, wiemy, że produkty zostały wyrenderowane.

template reference variable

<ul #scrolling class="products-container">

query the scrolling reference

scrollingRef = viewChild<HTMLElement>('scrolling');

Jednak signal queries zwracają wartość o typie sygnału. Nasz dotychczasowy kod to strumień rxJS. Zrefaktoryzujmy nasz kod, aby był zgodny z signal queries.

Opakujemy strumień rxJS, który zwraca pozycję przewijania w metodę toSignal:

const scrollingPosition: Signal<[number, number] | undefined> = toSignal(
  inject(Router).events.pipe(
    filter((event): event is Scroll => event instanceof Scroll),
    map((event: Scroll) => event.position || [0, 0]),
  ),
);

a w efekcie upewnimy się, że: 

  • nasze produkty są wyrenderowane if (this.scrollingRef())
  • wartość pozycji przewijania jest dostępna if (scrollingPosition())
effect(() => {
  if (this.scrollingRef() && scrollingPosition()) {
     this.viewportScroller.scrollToPosition(scrollingPosition()!);
   }
});

I na koniec zobaczmy, jak wygląda ostateczny kod:

export class ProductsComponent {
  products$ = inject(ProductsService).get();
  viewportScroller = inject(ViewportScroller);
  scrollingRef = viewChild<HTMLElement>('scrolling');

  constructor() {
    const scrollingPosition: Signal<[number, number] | undefined> = toSignal(
      inject(Router).events.pipe(
        filter((event): event is Scroll => event instanceof Scroll),
        map((event: Scroll) => event.position || [0, 0]),
      ),
    );

    effect(() => {
      if (this.scrollingRef() && scrollingPosition()) {
        this.viewportScroller.scrollToPosition(scrollingPosition()!);
      }
    });
  }
}

Ten krótki film ilustruje, jak to działa w przeglądarce!

Wniosek

Przywrócenie pozycji przewijania poprawia User Experience. 

Zaprezentowane rozwiązanie sprawdza się świetnie, ale nie jest aż tak skalowalne. Jeśli chcesz zobaczyć, jak uczynić je bardziej skalowalnym, gorąco zachęcam do obejrzenia mojego filmu na YouTube https://youtu.be/U7GeEkyv2Lk?si=55rivMSV43cUuvmx 

Dziękuję za przeczytanie mojego artykułu!

Podziel się artykułem

Zapisz się na nasz newsletter

Dołącz do community Angular.love i bądź na bieżąco z trendami.