uikit_tab_bar 0.1.0 copy "uikit_tab_bar: ^0.1.0" to clipboard
uikit_tab_bar: ^0.1.0 copied to clipboard

PlatformiOS

Native iOS 26+ tab bar (UITabBarController, Liquid Glass) for Flutter with minimize on scroll, accessory and search tab; CupertinoTabBar fallback elsewhere.

example/lib/main.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:uikit_tab_bar/uikit_tab_bar.dart';

void main() => runApp(const ExampleApp());

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

  @override
  State<ExampleApp> createState() => _ExampleAppState();
}

class _ExampleAppState extends State<ExampleApp> {
  ThemeMode themeMode = ThemeMode.system;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'uikit_tab_bar',
      debugShowCheckedModeBanner: false,
      themeMode: themeMode,
      theme: ThemeData(colorSchemeSeed: Colors.green),
      darkTheme: ThemeData(colorSchemeSeed: Colors.green, brightness: Brightness.dark),
      home: DemoHome(
        themeMode: themeMode,
        onThemeMode: (mode) => setState(() => themeMode = mode),
      ),
    );
  }
}

/// Every option of [UIKitTabBar], switchable at runtime in the "Optionen" tab.
class DemoOptions {
  bool hidden = false;
  bool accessory = true;
  bool prominent = false;
  bool search = true;
  bool autoSearch = false;
  UIKitTabBarMinimizeBehavior minimize = UIKitTabBarMinimizeBehavior.onScrollDown;
  bool tint = true;
  bool selectedColor = false;
  bool font = false;
  bool titleOffset = false;
  bool badgeColors = false;
  bool escBadge = false;
  bool badgeOffset = false;
  bool badgeFont = false;
  int badgeCount = 3;
  bool exploreDisabled = false;
  bool eventsHidden = false;
  bool subtitles = false;
  bool forceFallback = false;
}

class DemoHome extends StatefulWidget {
  const DemoHome({super.key, required this.themeMode, required this.onThemeMode});

  final ThemeMode themeMode;
  final ValueChanged<ThemeMode> onThemeMode;

  @override
  State<DemoHome> createState() => _DemoHomeState();
}

class _DemoHomeState extends State<DemoHome> {
  static const tabIds = ['home', 'explore', 'events', 'options', 'search'];

  final options = DemoOptions();
  final controller = UIKitTabBarController();
  final scrollControllers = {for (final id in tabIds) id: ScrollController()};
  String selected = 'home';
  String searchText = '';
  String lastEvent = '–';

  @override
  void dispose() {
    controller.dispose();
    for (final c in scrollControllers.values) {
      c.dispose();
    }
    super.dispose();
  }

  void _event(String text) {
    debugPrint('[example] $text');
    setState(() => lastEvent = text);
  }

  void _update(VoidCallback change) {
    setState(change);
    debugUseNativeTabBar = options.forceFallback ? false : null;
  }

  List<UIKitTab> get _tabs => [
        UIKitTab(
          id: 'home',
          label: 'Home',
          icon: const UIKitTabIcon.symbol('house'),
          selectedIcon: const UIKitTabIcon.symbol('house.fill'),
          fallbackIcon: const Icon(CupertinoIcons.house),
          fallbackSelectedIcon: const Icon(CupertinoIcons.house_fill),
          subtitle: options.subtitles ? 'Start' : null,
        ),
        UIKitTab(
          id: 'explore',
          label: 'Entdecken',
          // An app asset; resolution-aware (1x/2x/3x).
          icon: const UIKitTabIcon.image(AssetImage('assets/icons/star.png')),
          enabled: !options.exploreDisabled,
          subtitle: options.subtitles ? 'Neu' : null,
        ),
        UIKitTab(
          id: 'events',
          label: 'Events',
          // A Flutter IconData, rendered to a template image.
          icon: const UIKitTabIcon.icon(Icons.event_outlined),
          selectedIcon: const UIKitTabIcon.icon(Icons.event),
          badge: options.badgeCount > 0 ? '${options.badgeCount}' : null,
          hidden: options.eventsHidden,
          subtitle: options.subtitles ? 'Termine' : null,
        ),
        UIKitTab(
          id: 'options',
          label: 'Optionen',
          icon: const UIKitTabIcon.symbol('slider.horizontal.3'),
          fallbackIcon: const Icon(CupertinoIcons.slider_horizontal_3),
          badge: options.escBadge ? 'ESC' : null,
        ),
      ];

  UIKitTabBarStyle get _style => UIKitTabBarStyle(
        tintColor: options.tint ? Theme.of(context).colorScheme.primary : null,
        selectedColor: options.selectedColor ? Colors.deepOrange : null,
        titleFontAsset: options.font ? 'assets/fonts/Roboto-Bold.ttf' : null,
        titleFontSize: options.font ? 11 : null,
        titleOffset: options.titleOffset ? const Offset(0, -2) : null,
        badgeColor: options.badgeColors ? Colors.teal : null,
        badgeTextColor: options.badgeColors ? Colors.yellowAccent : null,
        // Pulls wide badges (like "ESC" on the last tab) back inside the bar.
        badgeOffset: options.badgeOffset ? const Offset(-8, 0) : null,
        badgeFontSize: options.badgeFont ? 9 : null,
        badgeFontWeight: options.badgeFont ? FontWeight.w600 : null,
      );

  @override
  Widget build(BuildContext context) {
    final bar = UIKitTabBar(
      tabs: _tabs,
      selectedId: selected,
      onSelected: (id) {
        setState(() => selected = id);
        _event('selected $id');
      },
      onReselect: (id) {
        _event('reselected $id → nach oben');
        final c = scrollControllers[id]!;
        if (c.hasClients) {
          c.animateTo(0, duration: const Duration(milliseconds: 400), curve: Curves.easeOut);
        }
      },
      searchTab: options.search
          ? UIKitSearchTab(
              label: 'Suche',
              placeholder: 'Stadt suchen',
              automaticallyActivatesSearch: options.autoSearch,
              onChanged: (text) => setState(() => searchText = text),
              onSubmitted: (text) => _event('search submitted "$text"'),
              onActiveChanged: (active) => _event('search active: $active'),
            )
          : null,
      prominentTabId: options.prominent ? 'events' : null,
      minimizeBehavior: options.minimize,
      hidden: options.hidden,
      accessory: options.accessory
          ? UIKitTabAccessory(
              title: 'Läuft gerade – Folge 7',
              subtitle: 'Podcast · noch 12 Min.',
              icon: const UIKitTabIcon.symbol('headphones'),
              actions: const [
                UIKitAccessoryAction(
                    id: 'pause', icon: UIKitTabIcon.symbol('pause.fill'), semanticLabel: 'Pause'),
                UIKitAccessoryAction(
                    id: 'next', icon: UIKitTabIcon.symbol('forward.fill'), semanticLabel: 'Weiter'),
              ],
              onTap: () => _event('accessory tapped'),
              onAction: (id) => _event('accessory action $id'),
            )
          : null,
      style: _style,
      controller: controller,
      onGeometryChanged: (g) => debugPrint('[example] $g'),
    );

    return Scaffold(
      resizeToAvoidBottomInset: false,
      body: UIKitTabScaffold(
        controller: controller,
        tabBar: bar,
        body: IndexedStack(
          index: tabIds.indexOf(selected).clamp(0, tabIds.length - 1),
          children: [
            _ListPage(title: 'Home', controller: scrollControllers['home']!),
            _ListPage(title: 'Entdecken', controller: scrollControllers['explore']!),
            _ListPage(title: 'Events', controller: scrollControllers['events']!),
            _OptionsPage(
              controller: scrollControllers['options']!,
              options: options,
              tabBarController: controller,
              themeMode: widget.themeMode,
              onThemeMode: widget.onThemeMode,
              lastEvent: lastEvent,
              onChanged: _update,
            ),
            _SearchPage(
              controller: scrollControllers['search']!,
              text: searchText,
              onFallbackChanged: (t) => setState(() => searchText = t),
              tabBarController: controller,
            ),
          ],
        ),
      ),
    );
  }
}

class _ListPage extends StatelessWidget {
  const _ListPage({required this.title, required this.controller});

  final String title;
  final ScrollController controller;

  @override
  Widget build(BuildContext context) {
    final media = MediaQuery.paddingOf(context);
    return ListView.builder(
      controller: controller,
      padding: EdgeInsets.fromLTRB(16, media.top + 16, 16, media.bottom + 16),
      itemCount: 41,
      itemBuilder: (context, i) {
        if (i == 0) return Text(title, style: Theme.of(context).textTheme.headlineMedium);
        return Container(
          height: 72,
          margin: const EdgeInsets.only(top: 10),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(16),
            gradient: LinearGradient(colors: [
              Colors.primaries[i % Colors.primaries.length],
              Colors.primaries[(i + 5) % Colors.primaries.length],
            ]),
          ),
          alignment: Alignment.center,
          child: Text('$title $i', style: const TextStyle(color: Colors.white, fontSize: 20)),
        );
      },
    );
  }
}

class _OptionsPage extends StatelessWidget {
  const _OptionsPage({
    required this.controller,
    required this.options,
    required this.tabBarController,
    required this.themeMode,
    required this.onThemeMode,
    required this.lastEvent,
    required this.onChanged,
  });

  final ScrollController controller;
  final DemoOptions options;
  final UIKitTabBarController tabBarController;
  final ThemeMode themeMode;
  final ValueChanged<ThemeMode> onThemeMode;
  final String lastEvent;
  final void Function(VoidCallback) onChanged;

  Widget _switch(String title, bool value, void Function(bool) set, {String? subtitle}) => SwitchListTile(
        title: Text(title),
        subtitle: subtitle == null ? null : Text(subtitle),
        value: value,
        onChanged: (v) => onChanged(() => set(v)),
      );

  @override
  Widget build(BuildContext context) {
    final media = MediaQuery.paddingOf(context);
    return ListView(
      controller: controller,
      padding: EdgeInsets.only(top: media.top, bottom: media.bottom + 16),
      children: [
        ListTile(
          title: Text('Optionen', style: Theme.of(context).textTheme.headlineMedium),
          subtitle: Text(UIKitTabBar.isNativeSupported ? 'Native UIKit-Bar (iOS 26+)' : 'Cupertino-Fallback'),
        ),
        ListTile(title: const Text('Letztes Ereignis'), subtitle: Text(lastEvent)),
        ValueListenableBuilder(
          valueListenable: tabBarController.geometry,
          builder: (context, g, _) => ListTile(
            title: const Text('Geometrie'),
            subtitle: Text(
              'bottomInset ${g.bottomInset.toStringAsFixed(1)} · minimiert ${g.minimized} · '
              'versteckt ${g.hidden} · Accessory ${g.accessoryEnvironment.name}',
            ),
          ),
        ),
        const _Header('Bar'),
        _switch('Ausblenden', options.hidden, (v) => options.hidden = v),
        ListTile(
          title: const Text('Minimieren'),
          subtitle: SegmentedButton<UIKitTabBarMinimizeBehavior>(
            showSelectedIcon: false,
            segments: [
              for (final b in UIKitTabBarMinimizeBehavior.values)
                ButtonSegment(value: b, label: Text(b.name, style: const TextStyle(fontSize: 11))),
            ],
            selected: {options.minimize},
            onSelectionChanged: (s) => onChanged(() => options.minimize = s.first),
          ),
        ),
        _switch('Bottom Accessory', options.accessory, (v) => options.accessory = v),
        _switch('Events hervorheben', options.prominent, (v) => options.prominent = v,
            subtitle: 'prominentTabIdentifier, iOS 27+'),
        _switch('Such-Tab', options.search, (v) => options.search = v),
        _switch('Suche sofort aktivieren', options.autoSearch, (v) => options.autoSearch = v,
            subtitle: 'automaticallyActivatesSearch'),
        const _Header('Tabs'),
        ListTile(
          title: Text('Badge Events: ${options.badgeCount}'),
          trailing: Row(mainAxisSize: MainAxisSize.min, children: [
            IconButton(
              icon: const Icon(Icons.remove),
              onPressed: () => onChanged(() => options.badgeCount = (options.badgeCount - 1).clamp(0, 99)),
            ),
            IconButton(
              icon: const Icon(Icons.add),
              onPressed: () => onChanged(() => options.badgeCount++),
            ),
          ]),
        ),
        _switch('Entdecken deaktiviert', options.exploreDisabled, (v) => options.exploreDisabled = v),
        _switch('Events versteckt', options.eventsHidden, (v) => options.eventsHidden = v),
        _switch('Untertitel', options.subtitles, (v) => options.subtitles = v),
        const _Header('Stil'),
        _switch('Tint aus dem Theme', options.tint, (v) => options.tint = v),
        _switch('Auswahlfarbe orange', options.selectedColor, (v) => options.selectedColor = v),
        _switch('App-Font (Roboto Bold)', options.font, (v) => options.font = v),
        _switch('Titel nach oben', options.titleOffset, (v) => options.titleOffset = v),
        _switch('Badge-Farben', options.badgeColors, (v) => options.badgeColors = v),
        _switch('Badge „ESC" an Optionen', options.escBadge, (v) => options.escBadge = v),
        _switch('Badges verschieben', options.badgeOffset, (v) => options.badgeOffset = v,
            subtitle: 'badgeOffset: Offset(-8, 0), gilt für alle Tabs'),
        _switch('Badge-Schrift klein', options.badgeFont, (v) => options.badgeFont = v,
            subtitle: 'badgeFontSize: 9, badgeFontWeight: w600'),
        ListTile(
          title: const Text('Erscheinungsbild'),
          subtitle: SegmentedButton<ThemeMode>(
            showSelectedIcon: false,
            segments: const [
              ButtonSegment(value: ThemeMode.system, label: Text('System')),
              ButtonSegment(value: ThemeMode.light, label: Text('Hell')),
              ButtonSegment(value: ThemeMode.dark, label: Text('Dunkel')),
            ],
            selected: {themeMode},
            onSelectionChanged: (s) => onThemeMode(s.first),
          ),
        ),
        const _Header('Plattform'),
        _switch('Fallback erzwingen', options.forceFallback, (v) => options.forceFallback = v,
            subtitle: 'CupertinoTabBar wie auf iOS < 26 und Android'),
        const _Header('Suche (Controller)'),
        ListTile(title: const Text('Suche aktivieren'), onTap: tabBarController.activateSearch),
        ListTile(
          title: const Text('Suchtext auf „Berlin" setzen'),
          onTap: () => tabBarController.setSearchText('Berlin'),
        ),
      ],
    );
  }
}

class _Header extends StatelessWidget {
  const _Header(this.text);
  final String text;

  @override
  Widget build(BuildContext context) => Padding(
        padding: const EdgeInsets.fromLTRB(16, 24, 16, 4),
        child: Text(text.toUpperCase(), style: Theme.of(context).textTheme.labelMedium),
      );
}

class _SearchPage extends StatelessWidget {
  const _SearchPage({
    required this.controller,
    required this.text,
    required this.onFallbackChanged,
    required this.tabBarController,
  });

  final ScrollController controller;
  final String text;
  final ValueChanged<String> onFallbackChanged;
  final UIKitTabBarController tabBarController;

  static const cities = [
    'Berlin',
    'Hamburg',
    'München',
    'Köln',
    'Frankfurt',
    'Stuttgart',
    'Düsseldorf',
    'Leipzig',
  ];

  @override
  Widget build(BuildContext context) {
    final media = MediaQuery.paddingOf(context);
    final hits = cities.where((c) => c.toLowerCase().contains(text.toLowerCase())).toList();
    return ValueListenableBuilder(
      valueListenable: tabBarController.geometry,
      builder: (context, geometry, _) => ListView(
        controller: controller,
        padding: EdgeInsets.fromLTRB(16, media.top + 16, 16, media.bottom + 16),
        children: [
          Text('Suche', style: Theme.of(context).textTheme.headlineMedium),
          // The native search field lives in the bar; the fallback needs one.
          if (!geometry.isNative)
            Padding(
              padding: const EdgeInsets.only(top: 12),
              child: CupertinoSearchTextField(onChanged: onFallbackChanged),
            ),
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 12),
            child: Text(text.isEmpty ? 'Alle Städte' : '${hits.length} Treffer für „$text"'),
          ),
          for (final city in hits) ListTile(leading: const Icon(Icons.location_city), title: Text(city)),
        ],
      ),
    );
  }
}
0
likes
160
points
--
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Native iOS 26+ tab bar (UITabBarController, Liquid Glass) for Flutter with minimize on scroll, accessory and search tab; CupertinoTabBar fallback elsewhere.

Repository (GitHub)
View/report issues

Topics

#tab-bar #ios #liquid-glass #cupertino #navigation

License

MIT (license)

Dependencies

flutter

More

Packages that depend on uikit_tab_bar

Packages that implement uikit_tab_bar