Search code examples
fluttermvvmflutter-layoutstream-builderflutter-pageview

Flutter PageView makes buggy preview of the content while swiping using SreamBuilder in MVVM


I'm doing a simple onboarding screen with PageView and faced a problem with repetitive and inconsistent preview during swiping and also laggy animation during swiping in PageView..

I have no clue why as i just trying Flutter and dart newly..

here's 3 screenshots, 2 for normal pages and one screenshot between them while scrolling. as shown the scroll between them is duplicated with the first content while scrolling ..

preview while scrolling is wrong and doesn't show the next content

here's the code for the onBoarding.dart and the onboardingViewModel.dart

class OnBoardingView extends StatefulWidget {
   const OnBoardingView({Key? key}) : super(key: key);
  @override
  State<OnBoardingView> createState() => _OnBoardingViewState();
}

class _OnBoardingViewState extends State<OnBoardingView> {
  final PageController _pageController = PageController(initialPage: 0,);
  final OnBoardingViewModel _viewModel = OnBoardingViewModel();

  _bind() {
    _viewModel.start();
  }

  @override
  void initState() {
    super.initState();
    _bind();
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<SliderViewObject>(
      stream: _viewModel.outputSliderViewObject,
      builder: (context, snapShot) {
        return _getContentWidget(snapShot.data);
      },

    );
  }

  _goNext() {
    Navigator.pushReplacementNamed(context, Routes.mainRoute);
  }

  Widget _getContentWidget(SliderViewObject? sliderViewObject) {
    if (sliderViewObject == null) {
      return  const Center(
        child: CircularProgressIndicator(),
      );
    } else {
      return Scaffold(
        backgroundColor: ColorManager.white,
        appBar: AppBar(
          backgroundColor: ColorManager.white,
          elevation: AppSize.s0,
          systemOverlayStyle: SystemUiOverlayStyle(
              statusBarColor: ColorManager.white,
              statusBarBrightness: Brightness.dark,
              statusBarIconBrightness: Brightness.dark),
        ),
        body: PageView.builder(
          controller: _pageController,
          itemCount: sliderViewObject.numOfSlides,

          onPageChanged: (index) {
              _viewModel.onPageChanged(index);
          },
          itemBuilder: (context, index) {
            return OnBoardingPage(sliderViewObject.sliderObject);
          },
        ),
        bottomSheet: Container(
          color: ColorManager.white,
          height: AppSize.s100,
          child: Column(
            children: [
               Spacer(),
              Align(
                alignment: Alignment.centerRight,
                child: TextButton(
                    style: TextButton.styleFrom(primary: ColorManager.primary),
                    onPressed: () {
                      _goNext();
                    },
                    child: Text(
                      AppStrings.skip,
                      textAlign: TextAlign.end,
                      style: Theme.of(context).textTheme.subtitle2,
                    )),
              ),
              Align(
                alignment: Alignment.center,
                child: _getBottomSheetWidget(sliderViewObject),
              ),
            ],
          ),
        ),
      );
    }
  }

  Widget _getBottomSheetWidget(SliderViewObject sliderViewObject) {
    return Container(
      color: ColorManager.primary,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          //left Arrow
          Padding(
            padding:  EdgeInsets.all(AppPadding.p8),
            child: GestureDetector(
              child: SizedBox(
                height: AppSize.s28,
                width: AppSize.s28,
                child: SvgPicture.asset(ImageAssets.leftArrow),
              ),
              onTap: () {
                // go to prev slide.
                _pageController.animateToPage(_viewModel.goPrevious(),
                    duration:
                         const Duration(milliseconds: DurationConstant.d300),
                    curve: Curves.bounceInOut);
              },
            ),
          ),

          // 4 circles
          Row(
            children: [
              for (int i = 0; i < sliderViewObject.numOfSlides; i++)
                Padding(
                  padding:  const EdgeInsets.all(AppPadding.p8),
                  child:
                      _getProperCircle(i, sliderViewObject.currentSlideIndex),
                ),
            ],
          ),

          //right Arrow
          Padding(
            padding:  EdgeInsets.all(AppPadding.p8),
            child: GestureDetector(
              child: SizedBox(
                height: AppSize.s28,
                width: AppSize.s28,
                child: SvgPicture.asset(ImageAssets.rightArrow),
              ),
              onTap: () {
                // go to next slide.
                _pageController.animateToPage(_viewModel.goNext(),
                    duration:
                         const Duration(milliseconds: DurationConstant.d300),
                    curve: Curves.bounceInOut);
              },
            ),
          ),
        ],
      ),
    );
  }

  Widget _getProperCircle(int index, int currentIndex) {
    if (index == currentIndex) {
      return SvgPicture.asset(ImageAssets.hollowCircle); //selected
    } else {
      return SvgPicture.asset(ImageAssets.solidCircle);
    }
  }

  @override
  void dispose() {
    _viewModel.dispose();
    _pageController.dispose();
    super.dispose();
  }
}

class OnBoardingPage extends StatelessWidget {
   SliderObject _sliderObject;
   OnBoardingPage(this._sliderObject, {Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.start,
      children: [
         const SizedBox(
          height: AppSize.s0,
        ),
        Padding(
          padding:  const EdgeInsets.all(AppPadding.p8),
          child: Text(
            _sliderObject.title,
            textAlign: TextAlign.center,
            style: Theme.of(context).textTheme.headline1,
          ),
        ),
        Padding(
          padding:  const EdgeInsets.all(AppPadding.p8),
          child: Text(
            _sliderObject.subTitle,
            textAlign: TextAlign.center,
            style: Theme.of(context).textTheme.subtitle1,
          ),
        ),
         const SizedBox(
          height: AppSize.s18,
        ),
        SvgPicture.asset(_sliderObject.image),
      ],
    );
  }
}

and here's the OnBoardingViewModel.dart to pass data using Stream and Sink

class OnBoardingViewModel extends BaseViewModel
    with OnBoardingViewModelInputs, OnBoardingViewModelOutputs {
  // stream controllers

  final StreamController _streamController = StreamController<SliderViewObject>();
  late final List<SliderObject> _list;

  int _currentIndex = 0;

  //inputs
  @override
  void dispose() {
    _streamController.close();
  }

  @override
  void start() {
    _list = _getSliderData(); //populate list
    //send data list  to view
    _postDataToView();
  }

  @override
  int goNext() {
    int nextIndex = _currentIndex++;
    print('TST user clicked prev and count = $_currentIndex ');
    if (nextIndex >= _list.length - 1) {
      _currentIndex = 0;
    }
    return _currentIndex;
  }

  @override
  int goPrevious() {
    int previousIndex = _currentIndex--;
    print('TST user clicked prev and count = $_currentIndex ');
    if (previousIndex <= 0) {
      _currentIndex = _list.length - 1;
    }
    return _currentIndex;
  }

  @override
  void onPageChanged(int index) {
    _currentIndex = index;
    _postDataToView();
  }

  @override
  void skip() {
    // TODO: implement skip
  }

  @override
  Sink get inputSliderViewObject => _streamController.sink;

  //outputs
  @override
  Stream<SliderViewObject> get outputSliderViewObject =>
      _streamController.stream.map((slideViewObject) => slideViewObject);

  List<SliderObject> _getSliderData() => [
        SliderObject(AppStrings.onBoardingTitle1,
            AppStrings.onBoardingSubTitle1, ImageAssets.onBoardingLogo1),
        SliderObject(AppStrings.onBoardingTitle2,
            AppStrings.onBoardingSubTitle2, ImageAssets.onBoardingLogo2),
        SliderObject(AppStrings.onBoardingTitle3,
            AppStrings.onBoardingSubTitle3, ImageAssets.onBoardingLogo3),
        SliderObject(AppStrings.onBoardingTitle4,
            AppStrings.onBoardingSubTitle4, ImageAssets.onBoardingLogo4)
      ];

  void _postDataToView() {
    inputSliderViewObject.add(
        SliderViewObject(_list[_currentIndex], _list.length, _currentIndex));
  }
}

class SliderViewObject {
  SliderObject sliderObject;
  int numOfSlides;
  int currentSlideIndex;
  SliderViewObject(this.sliderObject, this.numOfSlides, this.currentSlideIndex);
}

//orders received by the view
abstract class OnBoardingViewModelInputs {
  void goNext();

  void goPrevious();

  void onPageChanged(int index);

  void skip();

  Sink get inputSliderViewObject; //  stream input from the view
}

//orders sent to the view
abstract class OnBoardingViewModelOutputs {
  Stream<SliderViewObject>
      get outputSliderViewObject; //send the stream to the view.

}  

Solution

  • I think the problem is in passing the current index. We are passing the current index for the current slider object and also for the next one. So until the index changes, the images remain identical for the one and upcoming, and later when the current index changes both images change at the same time and are still the same.

    Considering you are referring to the Mina Farid course, here is how I solved it by passing a list of all objects:

    // In ViewModel

    void _postDataToView() {
        inputSliderViewObject.add(SliderViewObject(_sliderObjects[_currentIndex],
            _sliderObjects, _sliderObjects.length, _currentIndex));
      }
    
    class SliderViewObject {
      SliderObject sliderObject;
      List<SliderObject> list;
      int numberOfSlides;
      int currentIndex;
      SliderViewObject(
          this.sliderObject, this.list, this.numberOfSlides, this.currentIndex);
    } 
    

    /// Now you do as below in the view

    PageView.builder(
                  controller: _pageController,
                  itemCount: sliderViewObject.numberOfSlides,
                  onPageChanged: (index) {
                    _viewModel.onPageChanged(index);
                  },
                  itemBuilder: (context, index) {
                    return OnboardingItem(sliderViewObject.list[index]);
                  },
                ),
    

    Here you are passing current index for current image, and the next index for the next image. So this solves the issue..