Flutter: SliverAppBar ile Kategori Başlığı ve Arama Alanı
Son güncelleme:
7 dk okuma

Katalog, market ya da içerik listesi ekranlarında üstte bir arama kutusu ve altında yatay kaydırılan kategori chip'leri olur; liste aşağı kayarken büyük başlık toplanır, arama ve chip satırı ise yerinde kalır. Bu yazı tam olarak bu düzeni kuruyor: SliverAppBar'ın bottom ve flexibleSpace alanlarına arama kutusunu nereye koyacağını, pinned, floating, snap üçlüsünün hangi kombinasyonda ne yaptığını ve app bar'dan bağımsız yapışkan bir kategori satırı için SliverPersistentHeader'ı nasıl yazacağını göreceksin. Arada, detay ekranlarında sık görülen büyük görselli ve kaydırınca toplanan başlığı da kuruyoruz.
Canlı Demo
Aşağıdaki interaktif örnekte AppBar yapılarını deneyebilirsin:
💡 Eğer yukarıdaki örnek açılmazsa, DartPad linkine tıklayarak yeni sekmede çalıştırabilirsin.
Temel Yapı: bottom Alanında Arama Kutusu
SliverAppBar yalnızca bir CustomScrollView'ın slivers listesinde yaşar. En sade kurulum, sabit bir app bar ve onun bottom alanına yerleştirilmiş bir arama kutusudur:
CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
title: const Text('Kategoriler'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(64),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: TextField(
decoration: InputDecoration(
hintText: 'Kategori ara...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
isDense: true,
),
),
),
),
),
SliverList.builder(
itemCount: 40,
itemBuilder: (context, index) => ListTile(title: Text('Kategori $index')),
),
],
)bottom alanı PreferredSizeWidget ister; PreferredSize ile herhangi bir widget'a yükseklik bildirirsin. pinned: true olduğunda SliverAppBar toplanırken bottom alanını asla gizlemez: araç çubuğu ve bottom birlikte üstte kalır. Klasik AppBar'daki bottom ile aynı mantık; oradaki ayrıntılar için AppBar yazısına bakabilirsin.
pinned, floating ve snap Kombinasyonları
Üç bayrak birbirinden bağımsız görünür ama davranışı belirleyen kombinasyondur:
| Kombinasyon | Kaydırınca ne olur | Ne zaman seçilir |
|---|---|---|
| hiçbiri | App bar içerikle birlikte kayar gider, en üste dönünce gelir | Başlık önemsiz, alan değerli |
pinned |
Toplanır ama araç çubuğu + bottom üstte kalır |
Arama ve chip'ler hep erişilebilir olmalı |
floating |
Aşağı kaydırınca gider, yukarı kaydırmaya başlar başlamaz geri gelir | Arama ikincil, ama bir hamlede ulaşılmalı |
floating + snap |
Aynısı, ama yarım kalmaz: ya tam açılır ya tam kapanır | Titrek, yarı görünen app bar istemiyorsan |
pinned + floating |
bottom sabit kalır, üstteki genişleyen alan yukarı kaydırınca geri gelir |
Hem sabit arama hem geri gelen başlık |
snap, floating olmadan kullanılamaz; kombinasyonu yanlış kurarsan aşağıdaki hatalar bölümündeki assertion ile karşılaşırsın. expandedHeight ve flexibleSpace ise bu tablodan bağımsızdır: app bar'ın açıkken ne kadar yer kaplayacağını ve o alanda ne çizileceğini belirler.
Arama Kutusu bottom'da mı, flexibleSpace'te mi?
İki yerleşim iki farklı ürün kararıdır.
bottom: arama kutusu pinned ile birlikte her zaman görünür; kullanıcı listenin dibinde de arama yapabilir. Katalog ve market ekranlarının varsayılanı budur.
flexibleSpace: arama kutusu genişleyen alanın parçasıdır, liste kayınca başlıkla birlikte toplanır. Bu yerleşimi floating ve snap ile birleştirirsen küçük bir yukarı hareketle geri gelir:
SliverAppBar(
floating: true,
snap: true,
expandedHeight: 120,
title: const Text('Keşfet'),
flexibleSpace: FlexibleSpaceBar(
background: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: TextField(
decoration: InputDecoration(
hintText: 'Ara...',
prefixIcon: const Icon(Icons.search),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
),
),
),
),
),
)Kural olarak: kullanıcının etkileşime gireceği alanı (arama, filtre) bottom'a, yalnızca bilgi veren alanı (kategori adı, ürün sayısı, görsel) flexibleSpace'e koy. Etkileşimli bir alanı toplanan bölgeye koymak, "arama kutusu nereye gitti?" sorusunu doğurur.
Büyük Görselli Başlık: expandedHeight ve FlexibleSpaceBar
Detay ya da kampanya ekranlarında üstte büyük bir görsel olur; liste kaydıkça görsel toplanır, geriye sade bir araç çubuğu kalır. Açık haldeki yüksekliği expandedHeight, o alanda ne çizileceğini FlexibleSpaceBar belirler:
SliverAppBar(
pinned: true,
expandedHeight: 220,
flexibleSpace: FlexibleSpaceBar(
title: const Text('Keşfet', style: TextStyle(color: Colors.white)),
background: Stack(
fit: StackFit.expand,
children: [
Image.network(
'https://example.com/banner.jpg',
fit: BoxFit.cover,
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black54],
),
),
),
],
),
),
)FlexibleSpaceBar başlığı açıkken büyük, toplanırken küçük çizer; arka planı varsayılan CollapseMode.parallax ile içerikten daha yavaş kaydırır. Görselin altına eklenen koyulaşan gradyan, açık renkli bir fotoğrafın üzerindeki beyaz başlığı okunur tutar. pinned: true olduğu için görsel tamamen toplandığında araç çubuğu yüksekliğinde bir başlık üstte kalır; pinned olmadan başlık da görselle birlikte ekrandan çıkar. expandedHeight'ı küçük telefonlarda listenin ilk satırlarını gizleyecek kadar büyük seçme: görsel süs, liste ise ekranın asıl içeriği.
Kategori Chip Satırı: bottom İçinde
Chip satırı da bottom'a sığar; yatay bir ListView ile kaydırılabilir olur. Seçili kategoriyi State'te tutup ChoiceChip kullanırsın:
bottom: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: SizedBox(
height: 56,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: _categories.length,
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final category = _categories[index];
return ChoiceChip(
label: Text(category),
selected: category == _selected,
onSelected: (_) => setState(() => _selected = category),
);
},
),
),
),Arama kutusu ve chip satırı ikisi de bottom'da olacaksa ikisini bir Column'a koyup preferredSize yüksekliğini toplam yükseklik yaparsın. Ama chip satırının app bar'dan ayrı bir sliver olması genellikle daha esnektir; bir sonraki bölüm bunu anlatıyor.
SliverPersistentHeader ile Yapışkan Kategori Satırı
Chip satırını app bar'ın dışında, listenin başında bir banner'dan sonra ya da bölüm başlığı olarak yapıştırmak istiyorsan SliverPersistentHeader kullanırsın. Bu sliver bir SliverPersistentHeaderDelegate ister; delegate dört şeyi bilir: en küçük ve en büyük yükseklik, ne çizileceği ve ne zaman yeniden çizileceği:
class _StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
const _StickyHeaderDelegate({required this.child, this.height = 56});
final Widget child;
final double height;
@override
double get minExtent => height;
@override
double get maxExtent => height;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return Material(
color: Theme.of(context).colorScheme.surface,
elevation: overlapsContent ? 2 : 0, // içerik altına girince gölge
child: SizedBox.expand(child: child),
);
}
@override
bool shouldRebuild(covariant _StickyHeaderDelegate oldDelegate) =>
child != oldDelegate.child || height != oldDelegate.height;
}Kullanımı:
SliverPersistentHeader(
pinned: true,
delegate: _StickyHeaderDelegate(child: _buildCategoryChips()),
),minExtent ile maxExtent eşit olduğunda header küçülmez, sadece yapışır. Farklı verirsen build içine gelen shrinkOffset ile toplanma miktarını okuyup örneğin chip'leri küçültebilirsin. overlapsContent, içerik header'ın altına girdiğinde true olur; gölgeyi yalnızca o anda çizmek için ideal. shouldRebuild önemlidir: setState ile chip seçimi değişince yeni bir child üretilir, delegate bunu görüp header'ı yeniden çizer. Hep false döndürürsen seçim ekrana yansımaz.
Ne Zaman Kullanılır, Ne Zaman Kullanılmaz?
Bu düzeni, ekranın gövdesi tek bir uzun liste olduğunda ve üst alanın kaydırmaya tepki vermesi gerektiğinde seç: ürün kataloğu, haber akışı, kütüphane ekranı. Arama ile chip'lerin liste kayarken yerinde kalması ve büyük başlığın toplanması ancak sliver'larla mümkün.
Şu durumlarda daha basit ya da farklı bir araç doğru:
- Üst alan kaydırmaya tepki vermeyecekse
CustomScrollView'a gerek yok. KlasikAppBarilebottom'a arama kutusu koy, gövdeye normal birListViewver. Daha az kod, aynı görünüm. - Her kategori kendi sayfasıysa ve kullanıcı sağa-sola kaydırarak gezecekse chip yerine sekme kullan: TabBar ve TabBarView. Kaydırılabilir
TabBarchip satırının yerini alır. - Sekmeli sayfaların her birinin kendi kaydırılabilir listesi varsa ve app bar hepsinin üstünde toplanacaksa
NestedScrollViewgerekir;CustomScrollViewtek bir kaydırma alanı yönetir. Bu ayrımı ScrollView ve liste widget'ları yazısındaki NestedScrollView bölümünde anlattım. - Material 3'ün orta ve büyük başlık düzenleri gerekiyorsa
SliverAppBar.mediumveSliverAppBar.largeyapıcıları hazır gelir; kendiflexibleSpace'ini yazmadan önce onlara bak.
Sık Yapılan Hatalar
1. slivers listesine normal widget koymak
Belirti, kırmızı ekran ve şu mesaj:
A RenderViewport expected a child of type RenderSliver but received a child of type RenderBox.CustomScrollView.slivers yalnızca sliver kabul eder; Container, Text, Padding gibi kutu widget'ları doğrudan konamaz. Çözüm, kutuyu SliverToBoxAdapter ile sarmak:
slivers: [
const SliverAppBar(title: Text('Katalog')),
const SliverToBoxAdapter(
child: Padding(padding: EdgeInsets.all(16), child: Text('Öne çıkanlar')),
),
SliverList.builder(/* ... */),
]Aynı ailenin diğer üyeleri: kenar boşluğu için SliverPadding, kalan alanı doldurmak için SliverFillRemaining, ızgara için SliverGrid. SliverToBoxAdapter içine ListView koymak da ayrı bir hata üretir ("Vertical viewport was given unbounded height"); liste zaten SliverList olarak yazılmalı.
2. snap'i floating olmadan açmak
Belirti: uygulama daha ilk karede şu assertion ile duruyor:
The "snap" argument only makes sense for floating app bars.snap: true her zaman floating: true ile birlikte gelir. pinned bu ikisinden bağımsızdır; üçünü birden açmak geçerlidir.
3. bottom yüksekliği ile içerik yüksekliği uyuşmuyor
Belirti: chip'lerin alt kısmı kesik görünüyor ya da konsolda "A RenderFlex overflowed by N pixels on the bottom" uyarısı var. PreferredSize.preferredSize app bar'a ayrılacak alanı söyler; içerideki SizedBox ya da Padding bundan yüksekse taşar, alçaksa boşluk kalır. İkisini aynı sabitten üret:
const kChipRowHeight = 56.0;
PreferredSize(
preferredSize: const Size.fromHeight(kChipRowHeight),
child: SizedBox(height: kChipRowHeight, child: /* chip satırı */),
)4. TextEditingController'ı build içinde oluşturmak
Belirti: bir chip'e dokunup setState çağrılınca arama kutusundaki yazı siliniyor. build içinde TextEditingController() yazdıysan her yeniden çizimde yeni bir controller oluşur ve eski metin kaybolur. Controller'ı State'te bir alanda tut, dispose içinde kapat; aşağıdaki senaryo bunu yapıyor.
Mini Senaryo: Aranabilir Ürün Kataloğu
Bir ürün kataloğu düşün: üstte toplanan bir başlık, altında sabit bir arama kutusu, onun altında yapışkan bir kategori satırı ve filtrelenmiş liste. Hem arama metni hem seçili kategori listeyi süzüyor; sonuç boşsa ekran ortasında bir mesaj çıkıyor.
import 'package:flutter/material.dart';
typedef Product = ({String name, String category});
const _products = <Product>[
(name: 'Kablosuz Kulaklık', category: 'Elektronik'),
(name: 'Mekanik Klavye', category: 'Elektronik'),
(name: 'Koşu Ayakkabısı', category: 'Spor'),
(name: 'Yoga Matı', category: 'Spor'),
(name: 'Flutter Kitabı', category: 'Kitap'),
(name: 'Algoritma Kitabı', category: 'Kitap'),
];
const _categories = ['Tümü', 'Elektronik', 'Spor', 'Kitap'];
const _headerHeight = 56.0;
class CatalogPage extends StatefulWidget {
const CatalogPage({super.key});
@override
State<CatalogPage> createState() => _CatalogPageState();
}
class _CatalogPageState extends State<CatalogPage> {
final _searchController = TextEditingController();
String _query = '';
String _selected = 'Tümü';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<Product> get _filtered => _products.where((p) {
final inCategory = _selected == 'Tümü' || p.category == _selected;
final matches = p.name.toLowerCase().contains(_query.toLowerCase());
return inCategory && matches;
}).toList();
Widget _buildCategoryChips() {
return ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: _categories.length,
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final category = _categories[index];
return ChoiceChip(
label: Text(category),
selected: category == _selected,
onSelected: (_) => setState(() => _selected = category),
);
},
);
}
@override
Widget build(BuildContext context) {
final items = _filtered;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
expandedHeight: 160,
flexibleSpace: const FlexibleSpaceBar(
title: Text('Katalog'),
titlePadding: EdgeInsets.only(left: 16, bottom: 72),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(64),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: TextField(
controller: _searchController,
onChanged: (value) => setState(() => _query = value),
decoration: InputDecoration(
hintText: 'Ürün ara...',
prefixIcon: const Icon(Icons.search),
isDense: true,
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
),
),
),
SliverPersistentHeader(
pinned: true,
delegate: _StickyHeaderDelegate(
height: _headerHeight,
child: _buildCategoryChips(),
),
),
if (items.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: Text('Sonuç bulunamadı')),
)
else
SliverList.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final product = items[index];
return ListTile(
leading: const Icon(Icons.shopping_bag_outlined),
title: Text(product.name),
subtitle: Text(product.category),
);
},
),
],
),
);
}
}_StickyHeaderDelegate bir önceki bölümdeki sınıftır. Burada dört karar var. Başlık flexibleSpace'te, çünkü yalnızca bilgi veriyor; titlePadding'in alt değeri bottom alanının yüksekliğine göre verildi ki başlık arama kutusunun üstünde dursun. Arama kutusu bottom'da ve app bar pinned, dolayısıyla listenin neresinde olursan ol kutu yerinde. Chip satırı ayrı bir SliverPersistentHeader; app bar toplandıktan sonra hemen onun altına yapışıyor ve shouldRebuild sayesinde her seçimde yeniden çiziliyor. Boş sonuç için SliverFillRemaining kullanıldı; hasScrollBody: false ile mesaj kalan alanın ortasına yerleşiyor, ayrıca SliverToBoxAdapter ile boy hesaplamak gerekmiyor. TextEditingController State'te yaşadığı için chip seçince arama metni korunuyor.
Sık Sorulan Sorular
SliverAppBar ile AppBar arasındaki fark nedir?
AppBar sabit yükseklikte bir kutudur ve Scaffold.appBar alanına konur. SliverAppBar ise bir CustomScrollView'ın içinde yaşar ve kaydırmaya tepki verir: expandedHeight ile açılır, liste kayınca toplanır, pinned, floating ve snap ile ne zaman görüneceği ayarlanır. Üst alan kaydırmaya tepki vermeyecekse klasik AppBar yeterlidir.
"A RenderViewport expected a child of type RenderSliver" hatası ne demek?
CustomScrollView.slivers listesine normal bir kutu widget'ı koydun. O widget'ı SliverToBoxAdapter ile sar; liste için SliverList, kenar boşluğu için SliverPadding kullan.
Arama kutusunu bottom'a mı flexibleSpace'e mi koymalıyım?
Kullanıcı her an arama yapacaksa bottom ve pinned: true; kutu liste kayınca kaybolabilir, yukarı kaydırınca geri gelmeliyse flexibleSpace ile floating ve snap. Etkileşimli alanlar için varsayılan bottom'dır.
SliverPersistentHeader ile SliverAppBar arasındaki fark nedir?
SliverAppBar hazır bir araç çubuğudur: başlık, ikonlar, bottom, tema renkleri gelir. SliverPersistentHeader boş bir tuvaldir; ne çizileceğini ve yüksekliğini delegate ile sen belirlersin. Listenin ortasında yapışan bölüm başlıkları ve chip satırları için ikincisi uygundur.
pinned, floating ve snap üçü birden kullanılabilir mi?
Evet. snap yalnızca floating gerektirir; pinned bağımsızdır. Üçü birlikteyken bottom alanı sabit kalır, genişleyen üst alan ise yukarı kaydırmaya başlar başlamaz geri gelir ve yarım kalmadan tam açılır.
İlgili Yazılar
Flutter: Theme ve ThemeData ile Açık ve Koyu Tema
Flutter'da ThemeData ile tema kurmak: ColorScheme.fromSeed, açık ve koyu tema, ThemeMode, Theme.of ile okuma, TextTheme, bileşen temaları ve ThemeExtension.
Flutter: TextField ve TextEditingController Kullanımı
Flutter TextField rehberi: TextEditingController ve dispose, InputDecoration, klavye tipleri, inputFormatters, şifre alanı, FocusNode ve onChanged farkı.
Flutter: Stack ve Positioned ile Üst Üste Yerleşim
Flutter Stack ve Positioned rehberi: Stack'in boyutu, fit, alignment, clipBehavior, Positioned.fill, PositionedDirectional, rozet ve katman tarifleri, hatalar.