Flutter Effects – Membuat Efek Scrolling Parallax

Untuk teman-teman yang akan baru memulai belajar Flutter silahkan masuk ke artikel ini dulu ya teman-teman ➡ Aplikasi Pertamaku “Halo Semuaaa…“. Jika sudah yuk lanjut baca artikel ini…

Jangan lupa baca artikel sebelumnya ya teman-temanMembuat Filter Foto Carousel


Ketika kita melakukan scroll sebuah list cards (contoh: di dalamnya terdapat gambar) pada suatu aplikasi, mungkin kita akan memperhatikan bahwa gambar-gambar tersebut tampak bergulir lebih lambat daripada bagian layar lainnya. Hampir terlihat seolah-olah cards pada list berada di latar depan dan gambar yang ada di cards seolah-olah berada jauh di background list (Efek ini dikenal sebagai parallax).

Maka pada artikel ini kita akan menerapkan efek parallax pada sebuah list cards (dengan sudut membulat berisi beberapa baris teks). Setiap cards juga berisi gambar yang dimana ketika cards di-slide ke arah atas, gambar yang berada di dalam cards akan bergeser ke bawah. Contohnya sebagai berikut:

Parallax scrolling

Berikut langkah-langkah untuk membuatnya:

1. Membuat List untuk Menahan Parallax Items

Untuk menampilkan list dari gambar yang memiliki efek parallax, yang pertama kali harus kita lakukan yaitu menampilkan list.

Buat widget stateless baru bernama ParallaxRecipe dan di dalam ParallaxRecipe kita buat widget tree dengan SingleChildScrollView dan Column yang membentuk list.

class ParallaxRecipe extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return SingleChildScrollView(
     child: Column(
       children: [],
     ),
   );
 }
}

 2. Menampilkan Item dengan Teks dan Static Image

Setiap list item ditampilkan berbentuk persegi panjang sudut membulat dengan background sebuah gambar dan gambar tersebut berisi 7 lokasi terkenal di dunia. Kemudian di atas gambar tersebut terdapat nama lokasi dan negaranya yang diposisikan pada kiri bawah. Di antara gambar background dan teks terdapat gradien gelap yang akan mempermudah pengguna untuk membaca teks lokasi dan negara penjelas gambar background.

Implementasikan widget stateless yang diberi nama LocationListItem yang terdiri dari visual yang sudah kita bahas sebelumnya. Untuk saat ini kita akan menggunakan widget Image statik untuk background-nya, pada langkah selanjutnya baru kita ganti widget tersebut dengan versi parallax.

@immutable
class LocationListItem extends StatelessWidget {
 const LocationListItem({
   Key? key,
   required this.imageUrl,
   required this.name,
   required this.country,
 }) : super(key: key);

 final String imageUrl;
 final String name;
 final String country;

 @override
 Widget build(BuildContext context) {
   return Padding(
     padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
     child: AspectRatio(
       aspectRatio: 16 / 9,
       child: ClipRRect(
         borderRadius: BorderRadius.circular(16),
         child: Stack(
           children: [
             _buildParallaxBackground(context),
             _buildGradient(),
             _buildTitleAndSubtitle(),
           ],
         ),
       ),
     ),
   );
 }

 Widget _buildParallaxBackground(BuildContext context) {
   return Image.network(
     imageUrl,
     fit: BoxFit.cover,
   );
 }

 Widget _buildGradient() {
   return Positioned.fill(
     child: DecoratedBox(
       decoration: BoxDecoration(
         gradient: LinearGradient(
           colors: [Colors.transparent, Colors.black.withOpacity(0.7)],
           begin: Alignment.topCenter,
           end: Alignment.bottomCenter,
           stops: [0.6, 0.95],
         ),
       ),
     ),
   );
 }

 Widget _buildTitleAndSubtitle() {
   return Positioned(
     left: 20,
     bottom: 20,
     child: Column(
       mainAxisSize: MainAxisSize.min,
       crossAxisAlignment: CrossAxisAlignment.start,
       children: [
         Text(
           name,
           style: const TextStyle(
             color: Colors.white,
             fontSize: 20,
             fontWeight: FontWeight.bold,
           ),
         ),
         Text(
           country,
           style: const TextStyle(
             color: Colors.white,
             fontSize: 14,
           ),
         ),
       ],
     ),
   );
 }
}

Lalu tambahkan list items pada widget ParallaxRecipe.

class ParallaxRecipe extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return SingleChildScrollView(
     child: Column(
       children: [
         for (final location in locations)
           LocationListItem(
             imageUrl: location.imageUrl,
             name: location.name,
             location: location.place,
           ),
       ],
     ),
   );
 }
}

 3. Menerapkan Parallax Effect

Ketika efek parallax scrolling berjalan, efek tersebut akan sedikit mendorong gambar background ke arah yang berlawanan dari list yang ada. Saat item daftar bergeser ke atas layar, setiap gambar background bergeser sedikit ke bawah. Sebaliknya, saat item daftar bergeser ke bawah layar, setiap gambar background bergeser sedikit ke atas. Secara visual, ini menghasilkan parallax efect.

Efek paralaks bergantung pada posisi list items terkini pada Scrollable parrent-nya. Saat posisi scroll list items berubah, posisi gambar background list item juga harus berubah. Ini merupakan tantangan yang menarik untuk dipecahkan. Posisi list items dalam Scrollable tidak tersedia sampai fase Flutter layout selesai. Artinya, posisi gambar latar ditentukan oleh fase paint, yang terjadi setelah fase layout. Untungnya, Flutter menyediakan widget bernama Flow yang dirancang khusus untuk memberi kita kontrol atas transformasi widget  child tepat sebelum widget di-paint. Dengan kata lain, kita dapat menghentikan fase painting dan mengambil kendali untuk mengubah posisi widget child sesuka kita.

Bungkus background widget Image dengan widget  Flow.

Widget _buildParallaxBackground(BuildContext context) {
 return Flow(
   children: [
     Image.network(
       imageUrl,
       fit: BoxFit.cover,
     ),
   ],
 );
}

Definisikan FlowDelegate baru yang disebut ParallaxFlowDelegate.

Widget _buildParallaxBackground(BuildContext context) {
 return Flow(
   delegate: ParallaxFlowDelegate(),
   children: [
     Image.network(
       imageUrl,
       fit: BoxFit.cover,
     ),
   ],
 );
}

// …

class ParallaxFlowDelegate extends FlowDelegate {
 ParallaxFlowDelegate();

 @override
 BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
   // TODO:
 }

 @override
 void paintChildren(FlowPaintingContext context) {
   // TODO:
 }

 @override
 bool shouldRepaint(covariant FlowDelegate oldDelegate) {
   // TODO:
   return true;
 }
}

FlowDelegate  mengontrol bagaimana children diukur dan di mana children dicat. Dalam hal ini, widget Flow hanya memiliki satu child yaitu gambar background. Gambar tersebut harus memiliki lebar yang sama dengan widget Flow.

Return Constraints lebar untuk child gambar background.

 @override
 BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
   return BoxConstraints.tightFor(
    width: constraints.maxWidth,
   );
 }

Gambar background sekarang memiliki ukuran yang tepat, tapi kita tetap perlu memperhitungkan posisi vertikal setiap gambar background berdasarkan posisi scroll-nya kemudian mengecatnya.

Ada tiga informasi penting yang kita butuhkan untuk menghitung posisi gambar background yang diinginkan:

  • Batasan yang ada di parent Scrollable
  • Batasan individu antara list item
  • Ukuran gambar setelah diperkecil agar sesuai dengan list item

Untuk meliat batasan dari Scrollable, kita harus meneruskan ScrollableState ke FlowDelegate.

Untuk melihat batasan individu antara list item, kita harus meneruskan list item’s BuildContext keFlowDelegate.

Untuk melihat ukuran akhir gambar background, kita harus menetapkan sebuah GlobalKey pada widget Image dan kemudian kita meneruskan GlobalKey tersebut ke FlowDelegate.

class LocationListItem extends StatelessWidget {

 final GlobalKey _backgroundImageKey = GlobalKey();

 Widget _buildParallaxBackground(BuildContext context) {
   return Flow(
     delegate: ParallaxFlowDelegate(
       scrollable: Scrollable.of(context)!,
       listItemContext: context,
       backgroundImageKey: _backgroundImageKey,
     ),
     children: [
       Image.network(
         imageUrl,
         key: _backgroundImageKey,
         fit: BoxFit.cover,
       ),
     ],
   );
 }
}

// …

class ParallaxFlowDelegate extends FlowDelegate {
 ParallaxFlowDelegate({
   required this.scrollable,
   required this.listItemContext,
   required this.backgroundImageKey,
 });

 final ScrollableState scrollable;
 final BuildContext listItemContext;
 final GlobalKey backgroundImageKey;
}

Setelah kita memiliki semua informasi yang diperlukan untuk mengimplementasikan parallax scrolling, selanjutnya kita implementasikan method shouldRepaint().

@override
bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
 return scrollable != oldDelegate.scrollable ||
     listItemContext != oldDelegate.listItemContext ||
     backgroundImageKey != oldDelegate.backgroundImageKey;
}

Sekarang terapkan perhitungan layout untuk efek parallax. Pertama, hitung posisi pixel dari list item di dalam parent Scrollable.

@override
void paintChildren(FlowPaintingContext context) {
 // Calculate the position of this list item within the viewport.
 final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
 final listItemBox = listItemContext.findRenderObject() as RenderBox;
 final listItemOffset = listItemBox.localToGlobal(
     listItemBox.size.centerLeft(Offset.zero),
     ancestor: scrollableBox);
}

Selanjutnya gunakan posisi pixel dari list item untuk menghitung persentase dari atas Scrollable. List Item di bagian atas pada area yang dapat di-scroll harus menghasilkan 0%, dan List Item di bagian bawah pada area yang dapat di-scroll harus menghasilkan 100%.

@override
void paintChildren(FlowPaintingContext context) {
 // Calculate the position of this list item within the viewport.
 final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
 final listItemBox = listItemContext.findRenderObject() as RenderBox;
 final listItemOffset = listItemBox.localToGlobal(
     listItemBox.size.centerLeft(Offset.zero),
     ancestor: scrollableBox);

 // Determine the percent position of this list item within the
 // scrollable area.
 final viewportDimension = scrollable.position.viewportDimension;
 final scrollFraction =
   (listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);
}

Lalu gunakan persentase scroll untuk menghitung Alignment. Pada 0%, tetapkan Alignment(0.0, -1.0) dan pada 100% tetapkan Alignment(0.0, 1.0) dan setiap koordinat tersebut sesuai dengan Alignment top dan bottom.

@override
void paintChildren(FlowPaintingContext context) {
 // Calculate the position of this list item within the viewport.
 final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
 final listItemBox = listItemContext.findRenderObject() as RenderBox;
 final listItemOffset = listItemBox.localToGlobal(
     listItemBox.size.centerLeft(Offset.zero),
     ancestor: scrollableBox);

 // Determine the percent position of this list item within the
 // scrollable area.
 final viewportDimension = scrollable.position.viewportDimension;
 final scrollFraction =
   (listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);

 // Calculate the vertical alignment of the background
 // based on the scroll percentage.
 final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);
}

Gunakan verticalAlignment bersamaan dengan ukuran list item dan ukuran dari gambar background, untuk menghasilkan Rect yang menentukan dimana gambar background harus diposisikan.

@override
void paintChildren(FlowPaintingContext context) {
 // Calculate the position of this list item within the viewport.
 final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
 final listItemBox = listItemContext.findRenderObject() as RenderBox;
 final listItemOffset = listItemBox.localToGlobal(
     listItemBox.size.centerLeft(Offset.zero),
     ancestor: scrollableBox);

 // Determine the percent position of this list item within the
 // scrollable area.
 final viewportDimension = scrollable.position.viewportDimension;
 final scrollFraction =
   (listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);

 // Calculate the vertical alignment of the background
 // based on the scroll percentage.
 final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);

 // Convert the background alignment into a pixel offset for
 // painting purposes.
 final backgroundSize =
   (backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
       .size;
   final listItemSize = context.size;
   final childRect =
     verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);
}

Gunakan childRect untuk melukis gambar background dengan transformasi yang kita inginkan dan perubahan transformasi inilah yang akan memberikan efek parallax.

@override
void paintChildren(FlowPaintingContext context) {
 // Calculate the position of this list item within the viewport.
 final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
 final listItemBox = listItemContext.findRenderObject() as RenderBox;
 final listItemOffset = listItemBox.localToGlobal(
     listItemBox.size.centerLeft(Offset.zero),
     ancestor: scrollableBox);

 // Determine the percent position of this list item within the
 // scrollable area.
 final viewportDimension = scrollable.position.viewportDimension;
 final scrollFraction =
   (listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);

 // Calculate the vertical alignment of the background
 // based on the scroll percentage.
 final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);

 // Convert the background alignment into a pixel offset for
 // painting purposes.
 final backgroundSize =
   (backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
       .size;
   final listItemSize = context.size;
   final childRect =
     verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);

 // Paint the background.
 context.paintChild(
   0,
   transform: Transform.translate(
     offset: Offset(0.0, childRect.top),
   ).transform,
 );
}

Langkah akhir kita akan menambahkan detail pelengkap pada efek parallax, dimana ParallaxFlowDelegate mengecat ulang saat masukan berubah tapi ParallaxFlowDelegate tidak mengecat ulang setiap posisi scroll berubah. Oleh karena itu kita perlu meneruskan ScrollableState’s ScrollPosition ke superclass FlowDelegate sehingga FlowDelegate mengecat ulang setiap kali perubahan ScrollPosition.

class ParallaxFlowDelegate extends FlowDelegate {
 ParallaxFlowDelegate({
   required this.scrollable,
   required this.listItemContext,
   required this.backgroundImageKey,
 }) : super(repaint: scrollable.position);
}

Berikut cuplikan kode dan simulasinya, jika teman-teman menggunakan VSCode jalankan projectnya dengan menekan F5, klik hot reload (⚡) atau klik tombol ▶, berikut tampilannya (lakukan scroll ke atas dan ke bawah):


Jika ada pertanyaan silahkan komen dan jika artikel ini dirasa bermanfaat, jangan lupa like dan sharenya ya teman-teman. ??????? Sampai bertemu di artikel selanjutnya.
Terima Kasih, Assalamu’alaykum… Salam KODINGINDONESIA

Referensi : https://flutter.dev//

Anton Prafanto

Konten developer kodingindonesia.com & staf pengajar tetap di Universitas Mulawarman Samarinda

all author posts