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

Flutter: BottomSheet ve showModalBottomSheet Kullanımı

Ahmet Balaman

7 dk okuma

FlutterBottomSheetshowModalBottomSheetDraggableScrollableSheetMaterial 3UI
Flutter: BottomSheet ve showModalBottomSheet Kullanımı

Bottom sheet, ekranın altından kayarak açılan bir paneldir: bir fotoğrafın paylaşım seçenekleri, bir listenin filtreleri, kısa bir yorum formu. Başparmağa yakın durduğu için telefonda dialog'dan daha rahat kullanılır ve sayfanın bağlamını tamamen kapatmaz. Flutter'da iki türü vardır: modal sheet, arkasındaki sayfayı karartır, kapanana kadar etkileşimi engeller ve bir değer döndürebilir; kalıcı (persistent) sheet ise sayfanın bir parçası gibi durur ve alttaki içerikle etkileşim sürer. Bu yazıda ikisini, yükseklik ve klavye sorunlarını, sürüklenebilir sheet'i ve sık yapılan hataları anlatıyorum.

showModalBottomSheet ile Temel Kullanım

En sık kullanılan tür modal sheet'tir. showModalBottomSheet bir Future döndürür; sheet Navigator.pop(context, değer) ile kapandığında o değer gelir:

import 'package:flutter/material.dart';

enum PhotoAction { share, copyLink, delete }

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

  @override
  State<PhotoPage> createState() => _PhotoPageState();
}

class _PhotoPageState extends State<PhotoPage> {
  Future<void> _openActions() async {
    final action = await showModalBottomSheet<PhotoAction>(
      context: context,
      showDragHandle: true,
      useSafeArea: true,
      builder: (context) => Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          ListTile(
            leading: const Icon(Icons.share_outlined),
            title: const Text('Paylaş'),
            onTap: () => Navigator.pop(context, PhotoAction.share),
          ),
          ListTile(
            leading: const Icon(Icons.link),
            title: const Text('Bağlantıyı kopyala'),
            onTap: () => Navigator.pop(context, PhotoAction.copyLink),
          ),
          ListTile(
            leading: const Icon(Icons.delete_outline),
            title: const Text('Sil'),
            onTap: () => Navigator.pop(context, PhotoAction.delete),
          ),
          const SizedBox(height: 8),
        ],
      ),
    );

    if (!mounted || action == null) return;
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Seçilen: ${action.name}')),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Fotoğraf'),
        actions: [
          IconButton(onPressed: _openActions, icon: const Icon(Icons.more_vert)),
        ],
      ),
    );
  }
}

Üç ayrıntı önemli. Kullanıcı sheet'i aşağı kaydırarak ya da arka plana dokunarak kapatırsa Future null döner; action == null kontrolü bu yüzden şart. await sonrasındaki mounted kontrolü, sheet açıkken sayfa kapanmışsa geçersiz bir context kullanılmasını engeller. Column'a verilen mainAxisSize: MainAxisSize.min de sheet'in içerik kadar yer kaplamasını sağlar; neden gerektiğini MainAxisSize yazısında anlattım. Değer döndürme mantığı sayfa geçişlerindekiyle aynıdır, çünkü modal sheet de bir route'tur; ayrıntısı Navigator yazısında.

Bu örnekte işi sheet'in içinde yapmak yerine seçimi sayfaya döndürüp sonucu orada işledik. Bunun somut bir nedeni var: sheet açıkken sayfanın ScaffoldMessenger'ı ile gösterilen bir SnackBar, sayfanın Scaffold'unda çizildiği için sheet'in ve karartmanın arkasında kalır. Sheet yalnızca "kullanıcı ne seçti?" sorusunu cevaplayıp kapanırsa, mesaj göstermek, silme onayı istemek ya da istek atmak gibi işler sayfada, görünür bir yerde yapılır. Sheet'in kodu da sadeleşir ve başka ekranlarda aynen kullanılabilir.

Önemli Parametreler

Parametre Varsayılan Ne işe yarar?
isScrollControlled false true olunca sheet ekranın tamamına kadar uzayabilir
useSafeArea false Sheet'i üstteki durum çubuğu ve çentik gibi alanlardan uzak tutar
showDragHandle temadan, yoksa false Material 3'ün üstteki küçük tutma çubuğunu gösterir
isDismissible true Arka plana dokununca kapanıp kapanmayacağı
enableDrag true Aşağı kaydırarak kapatılıp kapatılamayacağı
backgroundColor, shape temadan Zemin rengi ve köşe şekli
constraints Material 3'te en fazla 640 piksel genişlik Sheet'in boyut sınırları
useRootNavigator false İç içe Navigator varsa sheet'i en üstteki Navigator'da açar

Görünümle ilgili olanların çoğu (showDragHandle, backgroundColor, shape, constraints) BottomSheetThemeData ile tema düzeyinde de verilebilir; aşağıda buna değiniyorum.

Yükseklik: isScrollControlled

Varsayılan hâliyle modal sheet, içeriği ne kadar uzun olursa olsun ekran yüksekliğinin en fazla 9/16'sına kadar büyür. Kısa bir seçenek listesi için bu yeterlidir. İçerik daha uzunsa alt kısım kesilir ya da taşar. Sheet'in ekranın tamamına kadar uzayabilmesi için isScrollControlled: true gerekir. Bu durumda useSafeArea: true vermek de iyi bir alışkanlıktır; yoksa uzun bir sheet durum çubuğunun altına kadar çıkabilir.

isScrollControlled: true sheet'i kendiliğinden tam ekran yapmaz; içerik ne kadar yer isterse o kadar uzar. Uzun içeriği SingleChildScrollView ya da bir liste içine koyduğunda, ekrana sığmayan kısım kaydırılabilir olur.

Klavye ile Form: viewInsets

Sheet'in içinde bir metin alanı varsa klavye açıldığında alan klavyenin arkasında kalır. Çözüm iki parçadır: isScrollControlled: true ile sheet'in yukarı uzamasına izin vermek ve içeriğin altına klavyenin yüksekliği kadar boşluk eklemek:

Future<String?> showCommentSheet(BuildContext context) {
  return showModalBottomSheet<String>(
    context: context,
    isScrollControlled: true,
    useSafeArea: true,
    showDragHandle: true,
    builder: (context) => Padding(
      padding: EdgeInsets.only(
        left: 16,
        right: 16,
        bottom: MediaQuery.viewInsetsOf(context).bottom + 16,
      ),
      child: TextField(
        autofocus: true,
        textInputAction: TextInputAction.send,
        decoration: const InputDecoration(labelText: 'Yorumun'),
        onSubmitted: (value) => Navigator.pop(context, value.trim()),
      ),
    ),
  );
}

MediaQuery.viewInsetsOf(context).bottom, o an ekranın alt kısmını kaplayan klavyenin yüksekliğidir; klavye kapalıyken 0 olur. Metin alanının kendisiyle ilgili ayrıntılar (odak, klavye tipi, onSubmitted) TextField yazısında.

DraggableScrollableSheet: Sürüklenebilir Sheet

Uzun bir liste gösteren sheet'in önce yarım açılıp kullanıcı yukarı çektikçe büyümesini istiyorsan DraggableScrollableSheet kullanılır:

Future<void> showCountryPicker(BuildContext context, List<String> countries) {
  return showModalBottomSheet<void>(
    context: context,
    isScrollControlled: true,
    useSafeArea: true,
    builder: (context) => DraggableScrollableSheet(
      expand: false,
      initialChildSize: 0.5,
      minChildSize: 0.3,
      maxChildSize: 0.95,
      snap: true,
      snapSizes: const [0.5],
      builder: (context, scrollController) => ListView.builder(
        controller: scrollController,
        itemCount: countries.length,
        itemBuilder: (context, index) => ListTile(
          title: Text(countries[index]),
          onTap: () => Navigator.pop(context),
        ),
      ),
    ),
  );
}

Boyutlar, sheet'in kullanabileceği toplam yüksekliğe oranla verilir: 0.5 bu alanın yarısı demektir. snap: true ve snapSizes, kullanıcı bıraktığında sheet'in belirli yüksekliklere oturmasını sağlar. İki kural var: builder'ın verdiği scrollController'ı listeye mutlaka ver, yoksa liste kayar ama sheet büyümez; modal sheet içinde expand: false kullan, yoksa sheet mevcut alanın tamamını kaplamaya çalışır. ListView.builder'ın kendisi için ListView yazısına bakabilirsin.

Kalıcı Bottom Sheet

Kalıcı sheet arkasını karartmaz ve sayfayla etkileşimi engellemez; bir müzik uygulamasındaki "şu an çalıyor" çubuğunu düşün. Scaffold.of(context).showBottomSheet ile açılır ve kapatmak için bir controller döndürür:

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

  @override
  State<PlayerPage> createState() => _PlayerPageState();
}

class _PlayerPageState extends State<PlayerPage> {
  PersistentBottomSheetController? _sheet;

  void _toggleSheet(BuildContext scaffoldContext) {
    if (_sheet != null) {
      _sheet!.close();
      return;
    }
    _sheet = Scaffold.of(scaffoldContext).showBottomSheet(
      (context) => const ListTile(
        leading: Icon(Icons.music_note),
        title: Text('Şu an çalıyor'),
        subtitle: Text('Parça adı'),
      ),
      showDragHandle: true,
    );
    _sheet!.closed.then((_) {
      if (mounted) setState(() => _sheet = null);
    });
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Çalma listesi')),
      body: Builder(
        builder: (scaffoldContext) => Center(
          child: FilledButton(
            onPressed: () => _toggleSheet(scaffoldContext),
            child: Text(_sheet == null ? 'Oynatıcıyı göster' : 'Gizle'),
          ),
        ),
      ),
    );
  }
}

Builder burada bilerek var: Scaffold.of bir context'ten yukarı doğru Scaffold arar, build metodunun kendi context'i ise Scaffold'un üstündedir. closed Future'ı, sheet kullanıcı tarafından aşağı kaydırılarak kapandığında da tamamlanır; bu yüzden durumu orada sıfırlıyoruz. Sheet'in hep görünür olması gerekiyorsa Scaffold'un bottomSheet parametresine bir widget vermek daha basittir; ancak bu parametre doluyken showBottomSheet çağrılamaz.

Kalıcı sheet, kullanıcının sayfayla çalışmaya devam ederken göz ucuyla takip ettiği bilgiler için uygundur: bir haritada seçili yerin özeti, müzik oynatıcı, sepet özeti. Kullanıcıdan bir karar beklenen durumlarda ise modal sheet daha doğrudur, çünkü kalıcı sheet bir sonuç döndürmez; ancak kapandığını closed üzerinden öğrenebilirsin.

Görünüm, Animasyon ve Tema

Her çağrıda showDragHandle: true yazmak yerine bunu temaya taşıyabilirsin: ThemeData(bottomSheetTheme: const BottomSheetThemeData(showDragHandle: true)). Zemin rengi, köşe şekli ve genişlik sınırı da aynı sınıftan ayarlanır. Bileşen temalarının genel mantığını Theme ve ThemeData yazısında anlattım.

Tutma çubuğu yalnızca görsel bir işaret değildir. Ekran okuyucu kullanan biri için "kapat" etiketli, dokunulabilir bir öğe olarak duyurulur ve sheet'i kapatır; aşağı kaydırma hareketini yapamayan kullanıcılar için bu önemli bir kolaylıktır. Sheet'in içinde ayrıca görünür bir kapatma ya da vazgeç butonu olması da aynı nedenle iyi bir alışkanlıktır.

Açılış ve kapanış hızını değiştirmek istersen sheetAnimationStyle parametresi var:

showModalBottomSheet<void>(
  context: context,
  sheetAnimationStyle: const AnimationStyle(
    duration: Duration(milliseconds: 400),
    reverseDuration: Duration(milliseconds: 250),
  ),
  builder: (context) => const SizedBox(height: 200),
);

Animasyonu tamamen kapatmak için AnimationStyle.noAnimation verilir. Varsayılan süreler Material yönergelerine göre seçildiği için bunu yalnızca gerçekten bir nedenin varsa değiştirmeni öneririm.

Mini Senaryo: Filtre Paneli

Bir ürün listesi düşün: sağ üstteki filtre ikonuna basınca sıralama, kategoriler ve "sadece stoktakiler" seçenekleri olan bir panel açılıyor, "Uygula" ile seçim sayfaya dönüyor, panel kaydırılarak kapatılırsa hiçbir şey değişmiyor. İkondaki rozet de kaç filtrenin aktif olduğunu gösteriyor:

enum SortOrder { newest, priceLow, priceHigh }

class ProductFilter {
  const ProductFilter({
    this.sort = SortOrder.newest,
    this.categories = const {},
    this.onlyInStock = false,
  });

  final SortOrder sort;
  final Set<String> categories;
  final bool onlyInStock;

  ProductFilter copyWith({
    SortOrder? sort,
    Set<String>? categories,
    bool? onlyInStock,
  }) {
    return ProductFilter(
      sort: sort ?? this.sort,
      categories: categories ?? this.categories,
      onlyInStock: onlyInStock ?? this.onlyInStock,
    );
  }
}

class FilterSheet extends StatefulWidget {
  const FilterSheet({super.key, required this.initial});

  final ProductFilter initial;

  @override
  State<FilterSheet> createState() => _FilterSheetState();
}

class _FilterSheetState extends State<FilterSheet> {
  static const _allCategories = ['Kitap', 'Elektronik', 'Giyim', 'Oyuncak'];
  static const _sortLabels = {
    SortOrder.newest: 'En yeni',
    SortOrder.priceLow: 'Fiyat artan',
    SortOrder.priceHigh: 'Fiyat azalan',
  };

  late ProductFilter _filter = widget.initial;

  void _toggleCategory(String category, bool selected) {
    final next = {..._filter.categories};
    selected ? next.add(category) : next.remove(category);
    setState(() => _filter = _filter.copyWith(categories: next));
  }

  @override
  Widget build(BuildContext context) {
    final text = Theme.of(context).textTheme;

    return SingleChildScrollView(
      padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        mainAxisSize: MainAxisSize.min,
        children: [
          Text('Sıralama', style: text.titleMedium),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            children: [
              for (final entry in _sortLabels.entries)
                ChoiceChip(
                  label: Text(entry.value),
                  selected: _filter.sort == entry.key,
                  onSelected: (_) =>
                      setState(() => _filter = _filter.copyWith(sort: entry.key)),
                ),
            ],
          ),
          const SizedBox(height: 16),
          Text('Kategoriler', style: text.titleMedium),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            children: [
              for (final category in _allCategories)
                FilterChip(
                  label: Text(category),
                  selected: _filter.categories.contains(category),
                  onSelected: (selected) => _toggleCategory(category, selected),
                ),
            ],
          ),
          SwitchListTile(
            contentPadding: EdgeInsets.zero,
            title: const Text('Sadece stoktakiler'),
            value: _filter.onlyInStock,
            onChanged: (value) =>
                setState(() => _filter = _filter.copyWith(onlyInStock: value)),
          ),
          const SizedBox(height: 8),
          Row(
            children: [
              Expanded(
                child: OutlinedButton(
                  onPressed: () => setState(() => _filter = const ProductFilter()),
                  child: const Text('Sıfırla'),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: FilledButton(
                  onPressed: () => Navigator.pop(context, _filter),
                  child: const Text('Uygula'),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

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

  @override
  State<ProductsPage> createState() => _ProductsPageState();
}

class _ProductsPageState extends State<ProductsPage> {
  ProductFilter _filter = const ProductFilter();

  Future<void> _openFilters() async {
    final result = await showModalBottomSheet<ProductFilter>(
      context: context,
      isScrollControlled: true,
      useSafeArea: true,
      showDragHandle: true,
      builder: (context) => FilterSheet(initial: _filter),
    );
    if (result == null) return; // Sheet kapatıldı, filtre değişmedi
    setState(() => _filter = result);
  }

  @override
  Widget build(BuildContext context) {
    final count = _filter.categories.length + (_filter.onlyInStock ? 1 : 0);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Ürünler'),
        actions: [
          IconButton(
            onPressed: _openFilters,
            tooltip: 'Filtrele',
            icon: Badge(
              isLabelVisible: count > 0,
              label: Text('$count'),
              child: const Icon(Icons.tune),
            ),
          ),
        ],
      ),
      body: Center(
        child: Text(
          'Sıralama: ${_filter.sort.name}\n'
          'Kategoriler: ${_filter.categories.isEmpty ? 'hepsi' : _filter.categories.join(', ')}\n'
          'Sadece stok: ${_filter.onlyInStock ? 'evet' : 'hayır'}',
          textAlign: TextAlign.center,
        ),
      ),
    );
  }
}

Bu senaryodaki en önemli karar, panelin kendi StatefulWidget'ı olması. Sheet ayrı bir route olarak açıldığı için sayfanın setState'i sheet'i yeniden çizmez; çip seçimleri gibi anlık değişiklikler sheet'in kendi state'inde tutulur. Panel, sayfanın filtresinin bir kopyasıyla başlıyor ve değişiklikler yalnızca "Uygula"ya basılınca geri dönüyor; kullanıcı vazgeçip paneli kaydırarak kapatırsa result null gelir ve sayfanın filtresi olduğu gibi kalır. Filtre değerleri copyWith ile değiştirilmez nesneler olarak taşındığı için "Sıfırla" tek satırda varsayılana dönebiliyor. Filtre ikonundaki sayı ise Material 3'ün hazır Badge widget'ıyla gösteriliyor.

Ne Zaman Bottom Sheet, Ne Zaman Başka Bir Şey?

Bottom sheet; birkaç seçenekli eylem listeleri, filtreler ve kısa formlar için idealdir. Kullanıcının mutlaka okuyup karar vermesi gereken bir onay ("Silinsin mi?") için AlertDialog daha uygundur, çünkü dikkati ekranın ortasında toplar. Bir butona bağlı kısa bir seçim listesi için PopupMenuButton daha hafiftir. Birden fazla adımı olan uzun formlar ise sheet'e sığdırılmaya çalışılmak yerine ayrı bir sayfada daha rahat doldurulur.

Sık Yapılan Hatalar

1. Uzun içeriğin yarıda kesilmesi

Belirti: Sheet ekranın ortasında duruyor, alttaki butonlar görünmüyor ya da taşma uyarısı çıkıyor. Varsayılan yükseklik sınırı ekranın 9/16'sıdır. isScrollControlled: true ver ve içeriği kaydırılabilir yap.

2. Klavyenin metin alanını kapatması

Sheet içindeki alana dokununca klavye alanın üstüne biniyor. isScrollControlled: true ile birlikte içeriğin altına MediaQuery.viewInsetsOf(context).bottom kadar boşluk ekle.

3. DraggableScrollableSheet'in büyümemesi

Liste kayıyor ama sheet yukarı uzamıyor: builder'ın verdiği scrollController listeye verilmemiştir. Sheet açılır açılmaz tüm ekranı kaplıyorsa da expand: false eksiktir.

4. Sheet içindeki değişikliklerin ekrana yansımaması

Sheet'in içeriğini sayfanın state'iyle kurup sayfada setState çağırmak sheet'i güncellemez, çünkü sheet ayrı bir route'tur. İçeriği kendi StatefulWidget'ına taşı ya da küçük durumlar için StatefulBuilder kullan. StatefulBuilder, builder fonksiyonuna kendi setState'ini verir; tek bir anahtar ya da sayaç gibi küçük durumlar için ayrı sınıf açmadan iş görür, ama içerik büyüdükçe ayrı bir widget çok daha okunur olur.

5. Scaffold.of hatası

Kalıcı sheet açarken şu hatayı alıyorsan:

Scaffold.of() called with a context that does not contain a Scaffold.

Scaffold'u oluşturan build metodunun context'ini kullanıyorsun demektir. Butonu bir Builder içine al ya da ayrı bir widget'a çıkar.

Sık Sorulan Sorular

Kullanıcının sheet'i yanlışlıkla kapatmasını nasıl engellerim?

isDismissible: false arka plana dokunarak, enableDrag: false aşağı kaydırarak kapatmayı engeller. Android'in geri tuşu ya da geri hareketi için sheet'in içeriğini PopScope(canPop: false, ...) ile sarabilirsin; kaydedilmemiş değişiklik uyarısı gösteren bir form için bu üçü birlikte kullanılır. Kullanıcıya her zaman görünür bir "Vazgeç" butonu bırakmayı unutma.

Tablette sheet neden ekranın ortasında dar görünüyor?

Material 3'te modal sheet'in varsayılan genişlik sınırı 640 pikseldir; geniş ekranlarda sheet bu genişlikte ortalanır. Tüm genişliği kaplamasını istiyorsan constraints: const BoxConstraints(maxWidth: double.infinity) verebilir ya da aynı ayarı BottomSheetThemeData üzerinden bütün uygulamaya uygulayabilirsin.

Sheet açıkken alttaki sayfaya dokunulabilir mi?

Modal sheet'te hayır; arkadaki karartma dokunuşları yakalar ve varsayılan olarak sheet'i kapatır. Alttaki sayfayla etkileşimin sürmesi gerekiyorsa kalıcı sheet (showBottomSheet ya da Scaffold.bottomSheet) kullanılır.

Sheet açıldığında alt navigasyon çubuğu neden üstte kalıyor?

Uygulaman her sekme için ayrı bir Navigator kullanıyorsa (iç içe navigasyon), showModalBottomSheet varsayılan olarak en yakın Navigator'da, yani sekmenin içinde açılır; alt navigasyon çubuğu da karartmanın dışında kalır. Sheet'in her şeyin üstünde açılması için useRootNavigator: true ver. Bu durumda sheet'i kapatan Navigator.pop çağrısı da sheet'in kendi context'iyle yapılmalıdır; builder'a gelen context'i kullandığın sürece sorun olmaz.

Yorumlar