shivam
shivam

Reputation: 485

Flutter PageView: Disable left or right side scrolling

I have a PageView, how can I disable the left or right scrolling. I know by using NeverScrollableScrollPhysics we can disable the scrolling but how to disable the scrolling in one direction.

Upvotes: 11

Views: 14791

Answers (7)

Aspiiire
Aspiiire

Reputation: 408

I tried the solution of diegoveloper and I found a more clean way to do it. This one locks the inverted scroll, it depends on the orientation, that's the reason why i havent used right or left, cause if you are using a listview up and down it will block the scroll up

class DirectionalScrollPhysics extends ScrollPhysics {
  DirectionalScrollPhysics({
    this.inverted = false,
    ScrollPhysics? parent,
  }) : super(parent: parent);

  final bool inverted;

  @override
  DirectionalScrollPhysics applyTo(ScrollPhysics? ancestor) {
    return DirectionalScrollPhysics(
        inverted: inverted, parent: buildParent(ancestor));
  }

  @override
  double applyBoundaryConditions(ScrollMetrics position, double value) {
    final bool condition = this.inverted ? value < 0 : value > 0;
    if (condition) return value;
    return 0.0;
  }
}

Then on your widget

ListView(
  scrollDirection: Axis.horizontal,
  physics: DirectionalScrollPhysics(
      parent: AlwaysScrollableScrollPhysics()),
  children: [],
),

Upvotes: 0

Roddy R
Roddy R

Reputation: 1470

Very simple

Use ScrollController and NotificationListener

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        home: Scaffold(backgroundColor: Colors.black, body: MyWidget()));
  }
}

class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  
  //Add this ScrollController
  final ScrollController scrollController = ScrollController();

  bool stop = false;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        //Add this NotificationListener
        NotificationListener<ScrollUpdateNotification>(
          onNotification: (notification) {
            print('${notification.scrollDelta}');
            if (notification.scrollDelta! < 0) {
              setState(() {
                stop = true;
              });
              
              //Add this jumpTo
              scrollController.jumpTo(
                  notification.metrics.pixels - notification.scrollDelta!);
            } else if (notification.scrollDelta! > 3) {
              setState(() {
                stop = false;
              });
            }
            return true;
          },
          child: ListView.builder(
              controller: scrollController,
              scrollDirection: Axis.horizontal,
              itemBuilder: (context, i) {
                return Container(
                    width: 100,
                    color: Colors.primaries[i % Colors.primaries.length]);
              }),
        ),
        if (stop)
          Center(
              child: Text(
            'STOP',
            style: Theme.of(context).textTheme.headline1,
          ))
      ],
    );
  }
}

Upvotes: 0

if you decide to use @Fabricio N. de Godoi's answer like me, there is a bug. For example after you blocked scroll right, scroll right will not work as expected. But just after that if you scroll the opposide direction page will scroll right.

And this is the solition; Find this code

if (_lock && ((lockLeft && isGoingLeft) || (lockRight && isGoingRight))) {
  _lock = false;
  return value - position.pixels;
}

And replace with

if (_lock && ((lockLeft && isGoingLeft) || (lockRight && isGoingRight))) {
  _lock = false;
  return value - position.pixels;
} else {
  _lock = true;
}

Upvotes: 0

Fabricio N. de Godoi
Fabricio N. de Godoi

Reputation: 51

Here is another version of the @diegoveloper and @wdavies973 ideas. I got the necessity of being able to only scroll with some programming command, ignoring the user gesture. Instead of some complex workaround in the main Widget, I was able to lock the screen to scroll using the ScrollPhysics behavior. When is user interaction, the applyPhysicsToUserOffset is called before applyBoundaryConditions. With this in mind, here is my sample, hope it help someone.

Note: Once locked, it is possible to navigate the pages using the controller.animateTo(pageIndex)


/// Custom page scroll physics
// ignore: must_be_immutable
class CustomLockScrollPhysics extends ScrollPhysics {
  /// Lock swipe on drag-drop gesture
  /// If it is a user gesture, [applyPhysicsToUserOffset] is called before [applyBoundaryConditions];
  /// If it is a programming gesture eg. `controller.animateTo(index)`, [applyPhysicsToUserOffset] is not called.
  bool _lock = false;

  /// Lock scroll to the left
  final bool lockLeft;

  /// Lock scroll to the right
  final bool lockRight;

  /// Creates physics for a [PageView].
  /// [lockLeft] Lock scroll to the left
  /// [lockRight] Lock scroll to the right
  CustomLockScrollPhysics({ScrollPhysics parent, this.lockLeft = false, this.lockRight = false})
      : super(parent: parent);

  @override
  CustomLockScrollPhysics applyTo(ScrollPhysics ancestor) {
    return CustomLockScrollPhysics(parent: buildParent(ancestor), lockLeft: lockLeft, lockRight: lockRight);
  }

  @override
  double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
    if ((lockRight && offset < 0) || (lockLeft && offset > 0)) {
      _lock = true;
      return 0.0;
    }

    return offset;
  }

  @override
  double applyBoundaryConditions(ScrollMetrics position, double value) {
    assert(() {
      if (value == position.pixels) {
        throw FlutterError('$runtimeType.applyBoundaryConditions() was called redundantly.\n'
            'The proposed new position, $value, is exactly equal to the current position of the '
            'given ${position.runtimeType}, ${position.pixels}.\n'
            'The applyBoundaryConditions method should only be called when the value is '
            'going to actually change the pixels, otherwise it is redundant.\n'
            'The physics object in question was:\n'
            '  $this\n'
            'The position object in question was:\n'
            '  $position\n');
      }
      return true;
    }());

    /*
     * Handle the hard boundaries (min and max extents)
     * (identical to ClampingScrollPhysics)
     */
    // under-scroll
    if (value < position.pixels && position.pixels <= position.minScrollExtent) {
      return value - position.pixels;
    }
    // over-scroll
    else if (position.maxScrollExtent <= position.pixels && position.pixels < value) {
      return value - position.pixels;
    }
    // hit top edge
    else if (value < position.minScrollExtent && position.minScrollExtent < position.pixels) {
      return value - position.pixels;
    }
    // hit bottom edge
    else if (position.pixels < position.maxScrollExtent && position.maxScrollExtent < value) {
      return value - position.pixels;
    }

    var isGoingLeft = value <= position.pixels;
    var isGoingRight = value >= position.pixels;
    if (_lock && ((lockLeft && isGoingLeft) || (lockRight && isGoingRight))) {
      _lock = false;
      return value - position.pixels;
    }

    return 0.0;
  }
}

Usage example:


@override
void initState() {
    super.initState();
    controller.tabController = TabController(initialIndex: 0, length: 3, vsync: this);
}

void nextTab() => controller.tabController.animateTo(controller.tabController.index + 1);

@override
void build(BuildContext context)
    return TabBarView(
        controller: controller.tabController,
        physics: CustomLockScrollPhysics(lockLeft: true),
        children: [ InkWell(onTap: nextTab), InkWell(onTap: nextTab), Container() ],
    ),
}

Final note: I tried everything to avoid using some variable in the class since ScrollPhysics is @immutable, but in this case, it was the only way to successfully know if the input was a user gesture.

Upvotes: 2

widavies
widavies

Reputation: 986

Came up with a slightly better approach for this.

This approach doesn't require instantiating new physics instances and can be updated dynamically.

Use the function parameter onAttemptDrag to allow or deny swipe requests. Your code in this function should be efficient as it will get called many times per second (when scrolling). Additionally, you may want to add a flag in this function that allows requests of programmatic origin to go through. For example, in the case of a "next button", this physics implementation would also block the functions jumpTo(..) and animateTo from working, so you'd need to have a flag that will temporarily return a default true for a specific page transition if the next button has been pressed. Let me know of any questions or ways to improve this.

class LockingPageScrollPhysics extends ScrollPhysics {
  /// Requests whether a drag may occur from the page at index "from"
  /// to the page at index "to". Return true to allow, false to deny.
  final Function(int from, int to) onAttemptDrag;

  /// Creates physics for a [PageView].
  const LockingPageScrollPhysics(
      {ScrollPhysics parent, @required this.onAttemptDrag})
      : super(parent: parent);

  @override
  LockingPageScrollPhysics applyTo(ScrollPhysics ancestor) {
    return LockingPageScrollPhysics(
        parent: buildParent(ancestor), onAttemptDrag: onAttemptDrag);
  }

  double _getPage(ScrollMetrics position) {
    if (position is PagePosition) return position.page;
    return position.pixels / position.viewportDimension;
  }

  double _getPixels(ScrollMetrics position, double page) {
    if (position is PagePosition) return position.getPixelsFromPage(page);
    return page * position.viewportDimension;
  }

  double _getTargetPixels(
      ScrollMetrics position, Tolerance tolerance, double velocity) {
    double page = _getPage(position);
    if (velocity < -tolerance.velocity)
      page -= 0.5;
    else if (velocity > tolerance.velocity) page += 0.5;
    return _getPixels(position, page.roundToDouble());
  }

  @override
  double applyBoundaryConditions(ScrollMetrics position, double value) {
    assert(() {
      if (value == position.pixels) {
        throw FlutterError('$runtimeType.applyBoundaryConditions() was called redundantly.\n'
            'The proposed new position, $value, is exactly equal to the current position of the '
            'given ${position.runtimeType}, ${position.pixels}.\n'
            'The applyBoundaryConditions method should only be called when the value is '
            'going to actually change the pixels, otherwise it is redundant.\n'
            'The physics object in question was:\n'
            '  $this\n'
            'The position object in question was:\n'
            '  $position\n');
      }
      return true;
    }());

    /*
     * Handle the hard boundaries (min and max extents)
     * (identical to ClampingScrollPhysics)
     */
    if (value < position.pixels && position.pixels <= position.minScrollExtent) // under-scroll
      return value - position.pixels;
    if (position.maxScrollExtent <= position.pixels && position.pixels < value) // over-scroll
      return value - position.pixels;
    if (value < position.minScrollExtent &&
        position.minScrollExtent < position.pixels) // hit top edge
      return value - position.minScrollExtent;
    if (position.pixels < position.maxScrollExtent &&
        position.maxScrollExtent < value) // hit bottom edge
      return value - position.maxScrollExtent;

    bool left = value < position.pixels;

    int fromPage, toPage;
    double overScroll = 0;

    if (left) {
      fromPage = position.pixels.ceil() ~/ position.viewportDimension;
      toPage = value ~/ position.viewportDimension;

      overScroll = value - fromPage * position.viewportDimension;
      overScroll = overScroll.clamp(value - position.pixels, 0.0);
    } else {
      fromPage =
          (position.pixels + position.viewportDimension).floor() ~/ position.viewportDimension;
      toPage = (value + position.viewportDimension) ~/ position.viewportDimension;

      overScroll = value - fromPage * position.viewportDimension;
      overScroll = overScroll.clamp(0.0, value - position.pixels);
    }

    if (fromPage != toPage && !onAttemptDrag(fromPage, toPage)) {
      return overScroll;
    } else {
      return super.applyBoundaryConditions(position, value);
    }
  }

Here's my implementation of PagePosition: https://gist.github.com/wdavies973/e596b8bb6b7ef773522e169464b53779

Here's a library I've been working on that uses this modified controller: https://github.com/RobluScouting/FlutterBoardView.

Upvotes: 2

robertohuertasm
robertohuertasm

Reputation: 886

You can also use the horizontal_blocked_scroll_physics library which I recently wrote that will allow you to block the left and right movements.

Upvotes: 6

diegoveloper
diegoveloper

Reputation: 103551

You can create your own ScrollPhysics to allow only go to the right:

            class CustomScrollPhysics extends ScrollPhysics {
              CustomScrollPhysics({ScrollPhysics parent}) : super(parent: parent);

              bool isGoingLeft = false;

              @override
              CustomScrollPhysics applyTo(ScrollPhysics ancestor) {
                return CustomScrollPhysics(parent: buildParent(ancestor));
              }

              @override
              double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
                isGoingLeft = offset.sign < 0;
                return offset;
              }

              @override
              double applyBoundaryConditions(ScrollMetrics position, double value) {
                //print("applyBoundaryConditions");
                assert(() {
                  if (value == position.pixels) {
                    throw FlutterError(
                        '$runtimeType.applyBoundaryConditions() was called redundantly.\n'
                        'The proposed new position, $value, is exactly equal to the current position of the '
                        'given ${position.runtimeType}, ${position.pixels}.\n'
                        'The applyBoundaryConditions method should only be called when the value is '
                        'going to actually change the pixels, otherwise it is redundant.\n'
                        'The physics object in question was:\n'
                        '  $this\n'
                        'The position object in question was:\n'
                        '  $position\n');
                  }
                  return true;
                }());
                if (value < position.pixels && position.pixels <= position.minScrollExtent)
                  return value - position.pixels;
                if (position.maxScrollExtent <= position.pixels && position.pixels < value)
                  // overscroll
                  return value - position.pixels;
                if (value < position.minScrollExtent &&
                    position.minScrollExtent < position.pixels) // hit top edge

                  return value - position.minScrollExtent;

                if (position.pixels < position.maxScrollExtent &&
                    position.maxScrollExtent < value) // hit bottom edge
                  return value - position.maxScrollExtent;

                if (!isGoingLeft) {
                  return value - position.pixels;
                }
                return 0.0;
              }
            }

Usage:

            @override
              Widget build(BuildContext context) {
                return Scaffold(
                    appBar: AppBar(),
                    body: PageView.builder(
                      itemCount: 4,
                      physics: CustomScrollPhysics(),
                      itemBuilder: (context, index) => Center(
                            child: Text("Item $index"),
                          ),
                    ));
              }

Upvotes: 8

Related Questions