Fintasys / emoji_picker_flutter

A Flutter package that provides an Emoji picker widget with 1500+ emojis in 8 categories.
MIT License
155 stars 114 forks source link

Controller's length property (9) does not match the number of I/flutter (31446): tabs (17) present in TabBar's tabs property. #106

Closed LostInDarkMath closed 1 year ago

LostInDarkMath commented 1 year ago

Context:

WhatsApp Image 2022-09-29 at 16 49 25

/flutter (31446): The following assertion was thrown during a scheduler callback:
I/flutter (31446): Controller's length property (9) does not match the number of
I/flutter (31446): tabs (17) present in TabBar's tabs property.
I/flutter (31446): 
I/flutter (31446): When the exception was thrown, this was the stack:
I/flutter (31446): #0      _TabBarState._debugScheduleCheckHasValidTabsCount.<anonymous closure>.<anonymous closure> (package:flutter/src/material/tabs.dart:1162:11)
I/flutter (31446): #1      _TabBarState._debugScheduleCheckHasValidTabsCount.<anonymous closure> (package:flutter/src/material/tabs.dart:1168:8)
I/flutter (31446): #2      SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1175:15)
I/flutter (31446): #3      SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1113:9)
I/flutter (31446): #4      SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:1015:5)
I/flutter (31446): #8      _invoke (dart:ui/hooks.dart:150:10)
I/flutter (31446): #9      PlatformDispatcher._drawFrame

The bug was not in version 1.3.1 of this library.

Fintasys commented 1 year ago

Thx @LostInDarkMath You have an example to reproduce? Our examples seemed to work fine. Will try to take a look asap 🙇

LostInDarkMath commented 1 year ago

Here is an minimal example that can reproduce this bug:

Dependencies

emoji_picker_flutter: 1.4.0
keyboard_utils: ^1.3.4

Code

import 'dart:io';

import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/material.dart' hide KeyboardListener;
import 'package:flutter/foundation.dart' as foundation;
import 'package:keyboard_utils/keyboard_utils.dart';
import 'package:keyboard_utils/keyboard_listener.dart';

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

class EmojiPickerWidget extends StatelessWidget {
  const EmojiPickerWidget({
    required this.textEditingController,
    super.key,
  });

  final TextEditingController textEditingController;

  @override
  Widget build(BuildContext context) {
    final shortestSide = MediaQuery.of(context).size.shortestSide;
    final theme = Theme.of(context);
    return EmojiPicker(
      textEditingController: textEditingController,
      config: Config(
        columns: shortestSide ~/ 50,
        emojiSizeMax: 32 * (!foundation.kIsWeb && Platform.isIOS ? 1.30 : 1.0),
        verticalSpacing: 0,
        horizontalSpacing: 0,
        initCategory: Category.RECENT,
        bgColor: theme.backgroundColor,
        indicatorColor: theme.indicatorColor,
        iconColor: theme.iconTheme.color!,
        iconColorSelected: theme.primaryColor,
        loadingIndicator: const Center(
          child: CircularProgressIndicator(
            valueColor: AlwaysStoppedAnimation<Color> (Colors.blue),
          ),
        ),
        showRecentsTab: true,
        recentsLimit: 28,
        noRecents: Padding(
          padding: const EdgeInsets.all(15.0),
          child: Text(
            'no recent emojis',
            textAlign: TextAlign.center,
            style: TextStyle(
              fontSize: 20,
              color: theme.brightness == Brightness.dark
                  ? Colors.white60
                  : Colors.black38,
            ),
          ),
        ),
        tabIndicatorAnimDuration: kTabScrollDuration,
        categoryIcons: const CategoryIcons(),
        buttonMode: ButtonMode.MATERIAL,
      ),
    );
  }
}

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

  @override
  State<Chat> createState() => _ChatState();
}

class _ChatState extends State<Chat> {
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
  final _textController = TextEditingController();
  final _keyboardUtils = KeyboardUtils();
  late int _idKeyboardListener;
  final focusNode = FocusNode();
  bool isEmojiKeyboardVisible = false;
  bool isKeyboardVisible = false;
  double _keyboardHeight = 0;
  final Set<double> _possibleKeyboardHeights = {};

  set keyboardHeight(double value) {
    if(!_possibleKeyboardHeights.contains(value)){
      return;
    }

    if(value == _keyboardHeight) {
      return;
    }

    _keyboardHeight = value;
  }

  @override
  void initState() {
    super.initState();
    _keyboardHeight = 300;

    _idKeyboardListener = _keyboardUtils.add(
      listener: KeyboardListener(
        willHideKeyboard: () {
          if(isKeyboardVisible) {
            isKeyboardVisible = false;
            isEmojiKeyboardVisible = false;
          } else {
          }

          setState(() {});
        },
        willShowKeyboard: (maybeCorrectKeyboardHeight) async {
          _possibleKeyboardHeights
            ..add(maybeCorrectKeyboardHeight)
            ..add(maybeCorrectKeyboardHeight + WidgetsBinding.instance.window.viewPadding.top / WidgetsBinding.instance.window.devicePixelRatio);
          isKeyboardVisible = true;
          isEmojiKeyboardVisible = true;
          setState(() {});
        },
      ),
    );
  }

  @override
  void didChangeDependencies(){
    super.didChangeDependencies();
    keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
  }

  @override
  void dispose(){
    _keyboardUtils.unsubscribeListener(subscribingId: _idKeyboardListener);

    if (_keyboardUtils.canCallDispose()) {
      _keyboardUtils.dispose();
    }

    focusNode.dispose();
    _textController.dispose();
    super.dispose();
  }

  Future<void> onEmojiButtonPressed() async {
    if(isEmojiKeyboardVisible){
      if(isKeyboardVisible){
        FocusManager.instance.primaryFocus?.unfocus();
        isKeyboardVisible = false;
      } else {
        focusNode.unfocus();
        await Future<void>.delayed(const Duration(milliseconds: 1));
        if(!mounted) return;
        FocusScope.of(context).requestFocus(focusNode);
      }
    } else {
      assert(!isKeyboardVisible, 'keyboard is visible');
      setState(() {
        isEmojiKeyboardVisible = true;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          Expanded(
            child: SizedBox(
              height: MediaQuery.of(context).size.height,
              width: MediaQuery.of(context).size.width,
              child: Column(
                children: [
                  Expanded(
                    child: Container(
                      color: Colors.green,
                      height: 200,
                    ),
                  ),
                  Row(
                    children: [
                      IconButton(
                        icon: Icon(isKeyboardVisible || !isEmojiKeyboardVisible ? Icons.emoji_emotions_outlined : Icons.keyboard_rounded),
                        onPressed: onEmojiButtonPressed,
                      ),
                      Expanded(
                        child: TextField(
                          controller: _textController,
                          focusNode: focusNode,
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ),
          ),
          Offstage(
            offstage: !isEmojiKeyboardVisible,
            child: SizedBox(
              height: _keyboardHeight,
              child: EmojiPickerWidget(
                textEditingController: _textController,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(),
    );
  }
}

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

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      resizeToAvoidBottomInset: false,
      appBar: AppBar(
        title: const Text('Bug demo'),
      ),
      body: const Chat(),
    );
  }
}

Steps to reproduce

Toggle between the keyboard and the emoji picker by using the IconButton. But the error only occurs sometimes. It's not very deterministic but the probability is high enough hopefully ;)

Error

The following assertion was thrown during a scheduler callback:
Controller's length property (9) does not match the number of tabs (17) present in TabBar's tabs property.

When the exception was thrown, this was the stack: 
#0      _TabBarState._debugScheduleCheckHasValidTabsCount.<anonymous closure>.<anonymous closure> (package:flutter/src/material/tabs.dart:1162:11)
#1      _TabBarState._debugScheduleCheckHasValidTabsCount.<anonymous closure> (package:flutter/src/material/tabs.dart:1168:8)
#2      SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1175:15)
#3      SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1113:9)
#4      SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:1015:5)
#5      _invoke (dart:ui/hooks.dart:148:13)
#6      PlatformDispatcher._drawFrame (dart:ui/platform_dispatcher.dart:318:5)
#7      _drawFrame (dart:ui/hooks.dart:115:31)

Hope that helps :)

Fintasys commented 1 year ago

@LostInDarkMath Thanks I could reproduce the issue and found that if noRecents Widget is not a const it's causing this issue. I'm not exactly sure why yet, but I assume having a non-const Widget in Config forces Flutter to rebuild it every time and then didUpdateWidget is calling _updateEmojis which is async and multiple rebuilds will lead to race condition and more tabs than we want :)

I think it's the same issue described in #81.

I will think of an solution or happy to hear suggestions, but for now I recommend to make sure all Widgets used inside Config are being a const Widget.

LostInDarkMath commented 1 year ago

@Fintasys Thanks for your fast reply!

Unfortunately, it is impossible to make noRecents a constant Widget because the displayed text is localized so that every user sees the text in his language.

I'll hope you find another solution for this. Have a nice day!

Fintasys commented 1 year ago

Wrapping your code inside a Stateless/Stateful Widget (own context) with const constructor also works. But I will try keep looking for better solution, i'm sure there must be something :)

 noRecents: const NoRecentEmoji(),

class NoRecentEmoji extends StatelessWidget {
  const NoRecentEmoji({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Padding(
      padding: const EdgeInsets.all(15.0),
      child: Text(
        'no recent emojis',
        textAlign: TextAlign.center,
        style: TextStyle(
          fontSize: 20,
          color: theme.brightness == Brightness.dark
              ? Colors.white60
              : Colors.black38,
        ),
      ),
    );
  }
}
Fintasys commented 1 year ago

Will close this issue for now. I have added information about it in ReadMe. If I come across a better solution I will add it. Thank you again for the example to replicate 🙇

LostInDarkMath commented 1 year ago

Thank you, that works fine! :+1: