How to zoom an item of a list on mouse-over, keeping it always visible (Flutter on Web platform)

Viewed 73

The problem is described like this.

In a web environment, I have to build a horizontal list of images (like in Netflix) which should increase the size of the element when the user positions the mouse cursor over them. To achieve this, I'm using a Stack (with clipBehavior equals to Clip.none) to render each item in the list, when I detect the mouse-over event I add a new Container (larger than the size of the original item) to draw an AnimatedContainer inside which will grow to fill it.

Image 1, normal list

The animation works great, but the container gets positioned down to the next right item on the list, however, I need it above the item.

Overlapped image

Here is the code:


    import 'package:flutter/material.dart';

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

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

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

    class MyHomePage extends StatefulWidget {
      const MyHomePage({Key? key, required this.title}) : super(key: key);

      final String title;

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

    class _MyHomePageState extends State {
      final double zoomTargetHeight = 320;
      final double zoomTargetWidth = 500;
      final double zoomOriginalHeight = 225;
      final double zoomOriginalWidth = 400;

      double _zoomHeight = 225;
      double _zoomWidth = 400;

      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: SingleChildScrollView(
            child: Column(
              children: [
                Image.network("https://source.unsplash.com/random/1600x900?cars"),
                Container(
                  color: Colors.black87,
                  child: Padding(
                    padding: const EdgeInsets.all(20.0),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        const SizedBox(
                          height: 12,
                        ),
                        const Text(
                          "List of items",
                          style: TextStyle(color: Colors.white),
                        ),
                        const SizedBox(
                          height: 12,
                        ),
                        SizedBox(
                          height: 235,
                          child: ListView.separated(
                            clipBehavior: Clip.none,
                            scrollDirection: Axis.horizontal,
                            itemBuilder: (context, index) {
                              return buildCard(index);
                            },
                            separatorBuilder: (context, index) {
                              return const SizedBox(
                                width: 12,
                              );
                            },
                            itemCount: 10,
                          ),
                        ),
                        const SizedBox(
                          height: 200,
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        );
      }

      Map _showZoom = {};

      Widget buildCard(int index) {
        Stack stack = Stack(
          clipBehavior: Clip.none,
          children: [
            MouseRegion(
              onEnter: (event) {
                setState(() {
                  _showZoom["$index"] = true;
                });
              },
              child: ClipRRect(
                borderRadius: BorderRadius.circular(20),
                child: Stack(
                  children: [
                    Image.network(
                        "https://source.unsplash.com/random/400x225?sig=$index&cars"),
                    Container(
                      color: Colors.black.withAlpha(100),
                      height: zoomOriginalHeight,
                      width: zoomOriginalWidth,
                    ),
                  ],
                ),
              ),
            ),
            if (_showZoom["$index"] != null && _showZoom["$index"]!)
              Positioned(
                left: (zoomOriginalWidth - zoomTargetWidth) / 2,
                top: (zoomOriginalHeight - zoomTargetHeight) / 2,
                child: MouseRegion(
                  onHover: (_) {
                    setState(() {
                      _zoomHeight = zoomTargetHeight;
                      _zoomWidth = zoomTargetWidth;
                    });
                  },
                  onExit: (event) {
                    setState(() {
                      _showZoom["$index"] = false;
                      _zoomHeight = zoomOriginalHeight;
                      _zoomWidth = zoomOriginalWidth;
                    });
                  },
                  child: SizedBox(
                    width: zoomTargetWidth,
                    height: zoomTargetHeight,
                    child: Center(
                      child: AnimatedContainer(
                        duration: const Duration(milliseconds: 400),

                        width: _zoomWidth,
                        height: _zoomHeight,
                        // color: Colors.green.withAlpha(100),
                        decoration: BoxDecoration(
                          borderRadius: BorderRadius.circular(20.0),
                          image: DecorationImage(
                            image: NetworkImage(
                                "https://source.unsplash.com/random/400x225?sig=$index&cars"),
                            fit: BoxFit.cover,
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
          ],
        );
        return stack;
      }
    }


Remember flutter config --enable-web

2 Answers

I think this is precisely what you are looking for (Check also the live demo on DartPad):

Screenshot

The solution is:

  1. Use an outer Stack that wraps the ListView;
  2. Add another ListView in front of it in the Stack with the same number of items and same item sizes;
  3. Then, ignore the pointer-events with IgnorePointer on this new ListView so the back one will receive the scroll/tap/click events;
  4. Synchronize the scroll between the back ListView and the front one by listening to scroll events with NotificationListener<ScrollNotification>;

Here's the code

import 'package:flutter/material.dart';

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

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

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

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

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

class _MyHomePageState extends State {
  final double zoomTargetHeight = 320;
  final double zoomTargetWidth = 500;
  final double zoomOriginalHeight = 225;
  final double zoomOriginalWidth = 400;

  late final ScrollController _controllerBack;
  late final ScrollController _controllerFront;

  @override
  void initState() {
    super.initState();
    _controllerBack = ScrollController();
    _controllerFront = ScrollController();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SingleChildScrollView(
        child: Column(
          children: [
            Image.network("https://source.unsplash.com/random/1600x900?cars"),
            Container(
              color: Colors.black87,
              child: Padding(
                padding: const EdgeInsets.all(20.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    const SizedBox(
                      height: 12,
                    ),
                    const Text(
                      "List of items",
                      style: TextStyle(color: Colors.white),
                    ),
                    const SizedBox(
                      height: 12,
                    ),
                    SizedBox(
                      height: 225,
                      child: NotificationListener<ScrollNotification>(
                        onNotification: (notification) {
                          _controllerFront.jumpTo(_controllerBack.offset);
                          return true;
                        },
                        child: Stack(
                          clipBehavior: Clip.none,
                          children: [
                            ListView.separated(
                              controller: _controllerBack,
                              clipBehavior: Clip.none,
                              scrollDirection: Axis.horizontal,
                              itemBuilder: (context, index) {
                                return buildBackCard(index);
                              },
                              separatorBuilder: (context, index) {
                                return const SizedBox(
                                  width: 12,
                                );
                              },
                              itemCount: 10,
                            ),
                            IgnorePointer(
                              child: ListView.separated(
                                controller: _controllerFront,
                                clipBehavior: Clip.none,
                                scrollDirection: Axis.horizontal,
                                itemBuilder: (context, index) {
                                  return buildFrontCard(index);
                                },
                                separatorBuilder: (context, index) {
                                  return const SizedBox(
                                    width: 12,
                                  );
                                },
                                itemCount: 10,
                              ),
                            ),
                          ],
                        ),
                      ),
                    ),
                    const SizedBox(
                      height: 200,
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  final Map _showZoom = {};

  Widget buildBackCard(int index) {
    return MouseRegion(
      onEnter: (event) {
        setState(() {
          _showZoom["$index"] = true;
        });
      },
      onExit: (event) {
        setState(() {
          _showZoom["$index"] = false;
        });
      },
      child: ClipRRect(
        borderRadius: BorderRadius.circular(20),
        child: Stack(
          children: [
            Image.network(
              "https://source.unsplash.com/random/400x225?sig=$index&cars",
            ),
            Container(
              color: Colors.black.withAlpha(100),
              height: zoomOriginalHeight,
              width: zoomOriginalWidth,
            ),
          ],
        ),
      ),
    );
  }

  Widget buildFrontCard(int index) {
    Widget child;
    double scale;
    if (_showZoom["$index"] == null || !_showZoom["$index"]!) {
      scale = 1;
      child = SizedBox(
        height: zoomOriginalHeight,
        width: zoomOriginalWidth,
      );
    } else {
      scale = zoomTargetWidth / zoomOriginalWidth;
      child = Stack(
        clipBehavior: Clip.none,
        children: [
          Container(
            height: zoomOriginalHeight,
            width: zoomOriginalWidth,
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(20.0),
              image: DecorationImage(
                image: NetworkImage(
                    "https://source.unsplash.com/random/400x225?sig=$index&cars"),
                fit: BoxFit.cover,
              ),
            ),
          ),
        ],
      );
    }
    return AnimatedScale(
      duration: const Duration(milliseconds: 400),
      scale: scale,
      child: child,
    );
  }
}

I'd do something different. Instead of Stacking the zoomed-out and zoomed-in images it could be just one image with a AnimatedScale to do the transitions.

Check the code below:

ClipRRect(
  borderRadius: BorderRadius.circular(20),
  child: Stack(
    children: [
      AnimatedScale(
        duration: const Duration(milliseconds: 400),
        scale: _showZoom["$index"] == true
            ? zoomTargetWidth / zoomOriginalWidth
            : 1,
        child: Image.network(
            "https://source.unsplash.com/random/400x225?sig=$index&cars"),
      ),
      if (_showZoom["$index"] == null || _showZoom["$index"] == false)
        Container(
          color: Colors.black.withAlpha(100),
          height: zoomOriginalHeight,
          width: zoomOriginalWidth,
        ),
    ],
  ),
),

Check out the screenshot and the live demo on DartPad:

Screenshot

All source

import 'package:flutter/material.dart';

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

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

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

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

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

class _MyHomePageState extends State {
  final double zoomTargetHeight = 320;
  final double zoomTargetWidth = 500;
  final double zoomOriginalHeight = 225;
  final double zoomOriginalWidth = 400;

  double _zoomHeight = 225;
  double _zoomWidth = 400;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SingleChildScrollView(
        child: Column(
          children: [
            Image.network("https://source.unsplash.com/random/1600x900?cars"),
            Container(
              color: Colors.black87,
              child: Padding(
                padding: const EdgeInsets.all(20.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    const SizedBox(
                      height: 12,
                    ),
                    const Text(
                      "List of items",
                      style: TextStyle(color: Colors.white),
                    ),
                    const SizedBox(
                      height: 12,
                    ),
                    SizedBox(
                      height: 235,
                      child: ListView.separated(
                        clipBehavior: Clip.none,
                        scrollDirection: Axis.horizontal,
                        itemBuilder: (context, index) {
                          return buildCard(index);
                        },
                        separatorBuilder: (context, index) {
                          return const SizedBox(
                            width: 12,
                          );
                        },
                        itemCount: 10,
                      ),
                    ),
                    const SizedBox(
                      height: 200,
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Map _showZoom = {};

  Widget buildCard(int index) {
    Stack stack = Stack(
      clipBehavior: Clip.none,
      children: [
        MouseRegion(
          onEnter: (event) {
            setState(() {
              _showZoom["$index"] = true;
            });
          },
          onExit: (event) {
            setState(() {
              _showZoom["$index"] = false;
            });
          },
          child: ClipRRect(
            borderRadius: BorderRadius.circular(20),
            child: Stack(
              children: [
                AnimatedScale(
                  duration: const Duration(milliseconds: 400),
                  scale: _showZoom["$index"] == true
                      ? zoomTargetWidth / zoomOriginalWidth
                      : 1,
                  child: Image.network(
                      "https://source.unsplash.com/random/400x225?sig=$index&cars"),
                ),
                if (_showZoom["$index"] == null || _showZoom["$index"] == false)
                  Container(
                    color: Colors.black.withAlpha(100),
                    height: zoomOriginalHeight,
                    width: zoomOriginalWidth,
                  ),
              ],
            ),
          ),
        ),
      ],
    );
    return stack;
  }
}
Related