İçeriğe geç / Skip to content / Zum Inhalt

Flutter: SingleChildScrollView, ListView ve NestedScrollView

Ahmet Balaman

Son güncelleme:

7 dk okuma

FlutterSingleChildScrollViewListViewNestedScrollViewScrollLayout
Flutter: SingleChildScrollView, ListView ve NestedScrollView

SingleChildScrollView, tek bir çocuğu (genellikle bir Column) kaydırılabilir hale getiren en sade kaydırma widget'ıdır. Bir giriş formu, ayarlar sayfası ya da ürün detay ekranı gibi "birbirinden farklı, sayısı belli birkaç widget'ın alt alta durduğu ve küçük ekranda taşabildiği" her yerde ilk aklına gelmesi gereken araç budur. Bu yazıda widget'ın kendisini, klavyeyle ilişkisini ve Column içindeki Expanded ile neden kavga ettiğini anlatıyorum; ardından ListView ve NestedScrollView'dan farkını ve hangi ekranda hangisinin seçileceğini gösteriyorum.

Canlı Demo

Aşağıdaki interaktif örnekte bu widget'ı deneyebilirsin:

💡 Eğer yukarıdaki örnek açılmazsa, DartPad linkine tıklayarak yeni sekmede çalıştırabilirsin.

SingleChildScrollView Nedir?

Adının söylediği gibi tek bir çocuğu vardır ve o çocuk ekrandan uzunsa aradaki fark kaydırılarak görülür. Çocuk ekrana sığıyorsa hiçbir şey olmaz; widget görünmez bir kutu gibi davranır. Bu yüzden "belki taşar" dediğin her sabit içerikli ekranı onunla sarmak güvenlidir.

Temel Kullanım

SingleChildScrollView(
  child: Column(
    children: [
      Container(height: 200, color: Colors.red),
      Container(height: 200, color: Colors.green),
      Container(height: 200, color: Colors.blue),
      Container(height: 200, color: Colors.yellow),
      // Ekranı aşan daha fazla içerik...
    ],
  ),
)

Aynı Column'ı sarmadan kullanırsan içerik küçük ekranda taşar ve altta o meşhur sarı-siyah çizgili "overflow" uyarısı belirir.

Yatay Kaydırma

scrollDirection: Axis.horizontal ile aynı şeyi Row için yaparsın. Yatay bir kart şeridi ya da geniş bir tablo için kullanışlıdır:

SingleChildScrollView(
  scrollDirection: Axis.horizontal,
  child: Row(
    children: [
      Container(width: 150, color: Colors.red),
      Container(width: 150, color: Colors.green),
      Container(width: 150, color: Colors.blue),
    ],
  ),
)

Önemli Özellikler

Özellik Açıklama
scrollDirection Kaydırma yönü (Axis.vertical / Axis.horizontal)
padding İçeriğin etrafındaki boşluk; kaydırma alanına dahildir
reverse Kaydırmayı sondan başlatır
physics Kaydırma davranışı (yaylanma, sınırda durma, hiç kaydırmama)
controller Konumu okumak ve programla kaydırmak için ScrollController
keyboardDismissBehavior onDrag verilirse kaydırınca klavye kapanır

Physics (Kaydırma Fiziği)

Varsayılan davranış platforma göre seçilir: iOS'ta yaylanma, Android'de sınırda durup mavi parlama. Bunu physics ile değiştirirsin:

  • BouncingScrollPhysics: iOS tarzı yaylanma.
  • ClampingScrollPhysics: Android tarzı, sonda duran davranış.
  • NeverScrollableScrollPhysics: kaydırmayı kapatır; iç içe kaydırmada içteki widget'a verilir.
  • AlwaysScrollableScrollPhysics: içerik kısa olsa bile kaydırmaya izin verir; RefreshIndicator ile aşağı çekip yenileme için gerekir.
SingleChildScrollView(
  physics: const BouncingScrollPhysics(),
  child: Column(children: [/* ... */]),
)

Klavye ve Form Ekranları

Scaffold, klavye açıldığında gövdeyi klavye kadar küçültür (resizeToAvoidBottomInset varsayılan olarak açıktır). Formun Column'ı kaydırılabilir değilse bu küçülmüş alana sığmaz ve taşar. SingleChildScrollView bu sorunu iki şekilde çözer: içerik küçülen alanda kaydırılabilir hale gelir ve odaklanan TextField kendiliğinden görünür alana kaydırılır; bunun için ekstra kod yazman gerekmez. Bir de keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag verirsen kullanıcı listeyi sürükleyince klavye kapanır:

SingleChildScrollView(
  keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
  padding: const EdgeInsets.all(16),
  child: Column(
    children: const [
      TextField(decoration: InputDecoration(labelText: 'Ad')),
      SizedBox(height: 16),
      TextField(decoration: InputDecoration(labelText: 'E-posta')),
      // ...
    ],
  ),
)

Form doğrulama ve TextFormField ayrıntıları için Form widget yazısına bakabilirsin.

Ne Zaman Kullanılır, Ne Zaman Kullanılmaz?

Column + SingleChildScrollView ikilisini, ekrandaki widget'lar birbirinden farklı ve sayısı önceden belliyken seç: giriş formu, profil düzenleme, ayarlar, ürün detayı, onboarding sayfası. Bu ekranlarda içerik zaten bir seferde oluşturulur, kaydırma yalnızca küçük ekranlar ve klavye için bir sigortadır.

Kaydırma widget’ı seçme akışı: içerik sığıyorsa Column, sayısı belli farklı widget’lar için SingleChildScrollView, uzun listeler için ListView.builder, ızgara için GridView

Şu durumlarda başka bir widget daha doğru:

  • Aynı türden ve sayısı belli olmayan satırlar gösteriyorsan (mesajlar, ürünler, arama sonuçları) ListView.builder kullan. SingleChildScrollView içindeki Column bütün satırları tek seferde oluşturur; 20 satırda fark etmez, 2.000 satırda ekran donar.
  • Öğeler ızgara halindeyse GridView, birkaç kaydırılabilir bölüm aynı ekranda birleşecekse (başlık, yatay liste, dikey liste) CustomScrollView ve sliver'lar gerekir. Toplanan bir başlığın altında sekmeli listeler varsa NestedScrollView gerekir; farkları bir sonraki bölümde.
  • İçerik her ekranda kesin sığıyorsa (üç butonlu bir onay ekranı gibi) kaydırmaya hiç gerek yok; düz Column yeter.

ListView ve NestedScrollView'dan Farkı

Üç widget da kaydırır, ama içeriği farklı biçimde oluşturur ve farklı sorunları çözer:

Widget İçeriği nasıl oluşturur Doğru olduğu yer
SingleChildScrollView Tek çocuğun tamamını bir seferde Sayısı belli, farklı widget'lar: form, ayarlar, detay
ListView.builder Yalnızca ekranda görünen satırları, kaydırdıkça Aynı türden, uzun ya da sayısı bilinmeyen satırlar
NestedScrollView Bir başlık alanı ile içteki kaydırılabilir gövdeyi eşgüdümlü kaydırır Toplanan başlık altında sekmeler ve her sekmede ayrı liste

ListView: görünen satırları üretir

ListView.builder satırları ekranda göründükçe oluşturur ve ekrandan çıkanları bırakır. Bin satırlık bir listede bile aynı anda yalnızca ekrana sığan kadarı ve biraz önbellek oluşturulur. Aynı listeyi SingleChildScrollView içindeki bir Column ile yazdığında bin satırın tamamı ilk karede oluşturulur.

ListView.builder(
  itemCount: 1000,
  itemBuilder: (context, index) => ListTile(title: Text('Satır $index')),
)

separatorBuilder, kaydırma kontrolü ve sonsuz liste gibi ayrıntıları ListView kullanımı yazısında, aynı veriyi ızgara halinde göstermeyi GridView yazısında anlattım.

NestedScrollView: başlık ve sekmeli listeler

NestedScrollView'ın resmi dokümandaki tipik kullanımı şu: başlıkta TabBar içeren toplanabilir bir SliverAppBar, gövdede her sekmenin kendi listesini taşıyan bir TabBarView. Tek bir CustomScrollView bunu yapamaz, çünkü her sekmenin ayrı bir kaydırma konumu vardır. NestedScrollView iki kaydırıcıyı birbirine bağlar: kullanıcı yukarı kaydırınca önce başlık toplanır, sonra içteki liste kaymaya başlar.

class ProfilePage extends StatelessWidget {
  const ProfilePage({super.key});

  static const _tabs = ['Gönderiler', 'Beğeniler'];

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: _tabs.length,
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (context, innerBoxIsScrolled) => [
            SliverOverlapAbsorber(
              handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
              sliver: SliverAppBar(
                title: const Text('Profil'),
                pinned: true,
                expandedHeight: 200,
                forceElevated: innerBoxIsScrolled,
                bottom: TabBar(
                  tabs: [for (final tab in _tabs) Tab(text: tab)],
                ),
              ),
            ),
          ],
          body: TabBarView(
            children: [
              for (final tab in _tabs)
                Builder(
                  builder: (context) => CustomScrollView(
                    key: PageStorageKey<String>(tab),
                    slivers: [
                      SliverOverlapInjector(
                        handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                      ),
                      SliverList.builder(
                        itemCount: 30,
                        itemBuilder: (context, index) =>
                            ListTile(title: Text('$tab $index')),
                      ),
                    ],
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

Üç ayrıntı bu kodu doğru yapıyor. SliverOverlapAbsorber ve her sekmedeki SliverOverlapInjector, sabitlenen app bar'ın kapladığı alanı içteki listeye bildirir; doküman, bunlar olmadan içteki listenin henüz kaydırılmamışken bile app bar'ın altına girebileceğini belirtiyor. Builder, sliverOverlapAbsorberHandleFor çağrısının NestedScrollView'ın altındaki bir context ile yapılması için var. PageStorageKey ise sekmeler arasında geçince her listenin kaydırma konumunun hatırlanmasını sağlar. Başlığın yukarı kaydırmaya başlar başlamaz geri gelmesini istiyorsan yalnızca floating: true yetmez; NestedScrollView'a floatHeaderSlivers: true da vermen gerekir.

Ekranda sekme yoksa ve tek bir liste varsa NestedScrollView'a gerek yoktur: başlığı ve listeyi aynı CustomScrollView içinde sliver olarak yazmak daha sadedir. Arama kutusu ve kategori satırı içeren bir başlık için SliverAppBar ile kategori başlığı ve arama alanı yazısındaki kurulum tam olarak bunu yapıyor.

Sık Yapılan Hatalar

1. Klavye açılınca "Bottom overflowed by N pixels"

Belirti: form ekranı normalde düzgün, ama bir alana dokunup klavye açılınca altta sarı-siyah çizgiler ve konsolda şu mesaj:

A RenderFlex overflowed by 142 pixels on the bottom.

Gövde klavye kadar küçüldü, Column ise küçülemedi. Çözüm Column'ı SingleChildScrollView ile sarmaktır; o kadar. resizeToAvoidBottomInset: false yaparak hatayı susturmak mümkündür ama o zaman klavye alt alanları örter ve kullanıcı son alana ulaşamaz.

2. SingleChildScrollView içinde Expanded ya da Spacer kullanmak

Belirti: ekran boş kalır ve konsolda şu hata görünür:

RenderFlex children have non-zero flex but incoming height constraints are unbounded.

Expanded, Column'da "kalan alanı" ister; SingleChildScrollView ise çocuğuna sınırsız yükseklik verir. Sınırsız alanın "kalanı" hesaplanamaz. Expanded kalan alanı doldurmak için konduysa çoğu zaman gereksizdir, kaldır ve widget'a doğal yüksekliğini bırak. "Buton en altta dursun ama içerik uzarsa kaydırılsın" istiyorsan IntrinsicHeight ve ConstrainedBox ile alt sınır yüksekliği veren şu kalıbı kullan:

LayoutBuilder(
  builder: (context, constraints) {
    return SingleChildScrollView(
      child: ConstrainedBox(
        constraints: BoxConstraints(minHeight: constraints.maxHeight),
        child: IntrinsicHeight(
          child: Column(
            children: [
              const Text('Üst içerik'),
              const Spacer(), // Artık çalışır
              FilledButton(onPressed: () {}, child: const Text('Devam')),
            ],
          ),
        ),
      ),
    );
  },
)

ConstrainedBox "en az ekran kadar uzun ol" der, IntrinsicHeight ise Column'a ölçülebilir bir yükseklik verir; böylece Spacer içerik kısayken boşluğu doldurur, içerik uzayınca da kaydırma devreye girer. Aynı kalıp, mainAxisAlignment: MainAxisAlignment.center'ın SingleChildScrollView içinde neden çalışmadığının da cevabıdır: Column içeriği kadar küçülür, ortalayacak fazla alanı kalmaz; alt sınır verince ortalama geri gelir. IntrinsicHeight ekstra bir ölçüm turu yapar, bu yüzden bunu uzun listelerde değil, form gibi kısa ekranlarda kullan.

3. İçine ListView ya da GridView koymak

Belirti: Vertical viewport was given unbounded height. hatası. İçteki liste de sınırsız yükseklik alır ve boyutunu hesaplayamaz. Kısa bir liste için shrinkWrap: true ve physics: const NeverScrollableScrollPhysics() ver; liste kendi yüksekliğini ölçer ve kaydırmayı dıştaki widget'a bırakır. Uzun bir liste için bu tembelliği öldürür; o durumda dıştaki SingleChildScrollView'dan vazgeç ve her şeyi CustomScrollView içinde sliver olarak birleştir.

4. Yatay kaydırmada Row'a Expanded vermek

Aynı hatanın yatay hali: SingleChildScrollView(scrollDirection: Axis.horizontal) içindeki Row'da Expanded kullanınca "incoming width constraints are unbounded" hatası alırsın. Yatay şeritte her çocuğa SizedBox(width: ...) gibi açık bir genişlik ver; Expanded'ın nasıl çalıştığını bilmek bu hatayı bir bakışta tanımanı sağlar.

Mini Senaryo: Giriş Ekranı

Klasik bir giriş ekranı düşün: üstte logo, ortada e-posta ve şifre alanları, en altta "Giriş Yap" butonu. Büyük ekranda buton altta sabit kalmalı, klavye açılınca içerik kaydırılabilmeli ve buton klavyenin üstüne çıkmalı. Bu, yukarıdaki IntrinsicHeight kalıbının tam yeri.

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      // Giriş isteğini burada gönder
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Giriş')),
      body: LayoutBuilder(
        builder: (context, constraints) {
          return SingleChildScrollView(
            keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
            padding: const EdgeInsets.all(24),
            child: ConstrainedBox(
              // padding üstten ve alttan 24 piksel alır
              constraints: BoxConstraints(minHeight: constraints.maxHeight - 48),
              child: IntrinsicHeight(
                child: Form(
                  key: _formKey,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: [
                      const FlutterLogo(size: 96),
                      const SizedBox(height: 32),
                      TextFormField(
                        controller: _emailController,
                        keyboardType: TextInputType.emailAddress,
                        decoration: const InputDecoration(labelText: 'E-posta'),
                        validator: (value) => (value == null || !value.contains('@'))
                            ? 'Geçerli bir e-posta gir'
                            : null,
                      ),
                      const SizedBox(height: 16),
                      TextFormField(
                        controller: _passwordController,
                        obscureText: true,
                        decoration: const InputDecoration(labelText: 'Şifre'),
                        validator: (value) => (value == null || value.length < 6)
                            ? 'En az 6 karakter'
                            : null,
                      ),
                      const Spacer(),
                      FilledButton(
                        onPressed: _submit,
                        child: const Text('Giriş Yap'),
                      ),
                    ],
                  ),
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}

Burada üç şey oluyor. LayoutBuilder, gövdenin o anki yüksekliğini veriyor; klavye açılınca bu değer küçülüyor, alt sınır da onunla birlikte küçülüyor ve içerik kaydırılabilir hale geliyor. Spacer, klavye kapalıyken butonu en alta itiyor. Odaklanan alan SingleChildScrollView sayesinde kendiliğinden görünür kalıyor. Kullanıcı bir alana yazarken listeyi sürüklerse klavye kapanıyor. Bütün bunlar için tek bir setState bile yazmadık; iş tamamen layout'un doğru kurulmasında.

Sık Sorulan Sorular

SingleChildScrollView ile ListView arasındaki fark nedir?

SingleChildScrollView tek bir çocuğu kaydırır ve o çocuğun tamamını bir seferde oluşturur; ListView ise satırları ekranda göründükçe üretir. Sayısı belli, farklı widget'lardan oluşan ekranlarda ilki, aynı türden çok sayıda satırda ikincisi doğrudur.

Klavye açılınca neden "bottom overflowed" hatası alıyorum?

Scaffold klavye açılınca gövdeyi küçültür ve kaydırılamayan bir Column yeni alana sığmaz. Column'ı SingleChildScrollView ile sarman yeterlidir; odaklanan TextField de kendiliğinden görünür alana kaydırılır.

SingleChildScrollView içinde Expanded neden çalışmıyor?

Kaydırma alanı çocuğuna sınırsız yükseklik verir ve Expanded'ın dolduracağı "kalan alan" tanımsız kalır. Expanded'ı kaldır ya da LayoutBuilder + ConstrainedBox(minHeight) + IntrinsicHeight kalıbıyla Column'a ölçülebilir bir yükseklik ver.

Column'ı SingleChildScrollView içinde dikeyde nasıl ortalarım?

Column içeriği kadar küçüldüğü için mainAxisAlignment: center tek başına etki etmez. ConstrainedBox ile minHeight olarak ekran yüksekliğini ver; Column en az ekran kadar uzar ve ortalama çalışır.

NestedScrollView ne zaman gerekli?

Toplanabilir bir başlığın altında birden fazla sekme varsa ve her sekmenin kendi kaydırılabilir listesi olacaksa. Tek bir liste ve üstünde toplanan bir başlık için CustomScrollView yeterlidir; NestedScrollView iki ayrı kaydırıcıyı eşgüdümlemek için vardır.

Yorumlar