[Flutter] Cookbook - Create a photo filter carousel

Create a photo filter carousel

사진에 다양한 필터를 적용해볼 수 있도록 filter carousel을 만들어보겠습니다.

Add a selector ring and dark gradient

먼저 Filter selector를 만들겠습니다.


class FilterSelector extends StatefulWidget {
 const FilterSelector({
   Key key,
 }) : super(key: key);

 
 _FilterSelectorState createState() => _FilterSelectorState();
}

class _FilterSelectorState extends State<FilterSelector> {
 
 Widget build(BuildContext context) {
   return SizedBox();
 }
}

Stack 을 이용하여 FilterSelector가 사진의 상위에 오도록 하고, 화면에서 아래쪽에 위치시킵니다.

Stack(
 children: [
   Positioned.fill(
     child: _buildPhotoWithFilter(),
   ),
   Positioned(
     left: 0.0,
     right: 0.0,
     bottom: 0.0,
     child: FilterSelector(),
   ),
 ],
),

FilterSelector안에 slector ring을 만들어넣습니다.

class _FilterSelectorState extends State<FilterSelector> {
 static const _filtersPerScreen = 5;
 static const _viewportFractionPerItem = 1.0 / _filtersPerScreen;

 
 Widget build(BuildContext context) {
   return LayoutBuilder(
     builder: (context, constraints) {
       final itemSize = constraints.maxWidth * _viewportFractionPerItem;

       return Stack(
         alignment: Alignment.bottomCenter,
         children: [
           _buildShadowGradient(itemSize),
           _buildSelectionRing(itemSize),
         ],
       );
     },
   );
 }

 Widget _buildShadowGradient(double itemSize) {
   return SizedBox(
     height: itemSize * 2 + widget.padding.vertical,
     child: const DecoratedBox(
       decoration: BoxDecoration(
         gradient: LinearGradient(
           begin: Alignment.topCenter,
           end: Alignment.bottomCenter,
           colors: [
             Colors.transparent,
             Colors.black,
           ],
         ),
       ),
       child: SizedBox.expand(),
     ),
   );
 }

 Widget _buildSelectionRing(double itemSize) {
   return IgnorePointer(
     child: Padding(
       padding: widget.padding,
       child: SizedBox(
         width: itemSize,
         height: itemSize,
         child: const DecoratedBox(
           decoration: BoxDecoration(
             shape: BoxShape.circle,
             border: Border.fromBorderSide(
               BorderSide(width: 6.0, color: Colors.white),
             ),
           ),
         ),
       ),
     ),
   );
 }
}

Create a filter carousel item

carousel에 들어갈 item widget을 생성합니다.


class FilterItem extends StatelessWidget {
 FilterItem({
   Key key,
    this.color,
   this.onFilterSelected,
 }) : super(key: key);

 final Color color;
 final VoidCallback? onFilterSelected;

 
 Widget build(BuildContext context) {
   return GestureDetector(
     onTap: onFilterSelected,
     child: AspectRatio(
       aspectRatio: 1.0,
       child: Padding(
         padding: const EdgeInsets.all(8.0),
         child: ClipOval(
           child: Image.network(
             'https://flutter.dev/docs/cookbook/img-files'
             '/effects/instagram-buttons/millenial-texture.jpg',
             color: color.withOpacity(0.5),
             colorBlendMode: BlendMode.hardLight,
           ),
         ),
       ),
     ),
   );
 }
}

Implement the filter carousel

pageView 를 이용해서 좌우로 스크롤 가능한 filter carousel을 생성합니다.

  • pageViewControllerviewportFraction 을 이용해서 각 filterItem widget들의 사이즈와 투명도를 조절합니다.
  • AnimatedBuilder 사용하여 controller가 스크롤위치를 변화시킬 때마다 filterItem의 사이즈와 투명도를 알맞게 조절하도록합니다.
class _FilterSelectorState extends State<FilterSelector> {
 final PageController _controller;
 
 Color itemColor(int index) => widget.filters[index % widget.filters.length];


 
 void initState() {
   super.initState();
   _controller = PageController(
     viewportFraction: _viewportFractionPerItem,
   );
   _controller.addListener(_onPageChanged);
 }

 void _onPageChanged() {
   final page = (_controller.page ?? 0).round();
   widget.onFilterChanged(widget.filters[page]);
 }

 
 void dispose() {
   _controller.dispose();
   super.dispose();
 }

 Widget _buildCarousel(double itemSize) {
  return Container(
    height: itemSize,
    margin: widget.padding,
    child: PageView.builder(
      controller: _controller,
      itemCount: widget.filters.length,
      itemBuilder: (context, index) {
        return Center(
          child: AnimatedBuilder(
            animation: _controller,
            builder: (context, child) {
              if (!_controller.hasClients ||
                !_controller.position.hasContentDimensions) {
                return SizedBox();
              }

              final selectedIndex = _controller.page!.roundToDouble();
              final pageScrollAmount = _controller.page! - selectedIndex;
              final maxScrollDistance = _filtersPerScreen / 2;
              final pageDistanceFromSelected =
   (selectedIndex - index + pageScrollAmount).abs();
              final percentFromCenter =
   1.0 - pageDistanceFromSelected / maxScrollDistance;

              final itemScale = 0.5 + (percentFromCenter * 0.5);
              final opacity = 0.25 + (percentFromCenter * 0.75);

              return Transform.scale(
                scale: itemScale,
                child: Opacity(
                  opacity: opacity,
                  child: FilterItem(
                    color: itemColor(index),
                    onFilterSelected: () => _onFilterTapped,
                  ),
                ),
              );
            },
          ),
        );
      },
    ),
  );
}

void _onFilterTapped(int index) {
 _controller.animateToPage(
   index,
   duration: const Duration(milliseconds: 450),
   curve: Curves.ease,
 );
}


}

Source Code

mport 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ViewportOffset;

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

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: ExampleInstagramFilterSelection(),
      debugShowCheckedModeBanner: false,
    );
  }
}


class ExampleInstagramFilterSelection extends StatefulWidget {
  const ExampleInstagramFilterSelection({Key key}) : super(key: key);

  
  _ExampleInstagramFilterSelectionState createState() =>
      _ExampleInstagramFilterSelectionState();
}

class _ExampleInstagramFilterSelectionState
    extends State<ExampleInstagramFilterSelection> {
  final _filters = [
    Colors.white,
    ...List.generate(
      Colors.primaries.length,
      (index) => Colors.primaries[(index * 4) % Colors.primaries.length],
    )
  ];

  final _filterColor = ValueNotifier<Color>(Colors.white);

  void _onFilterChanged(Color value) {
    _filterColor.value = value;
  }

  
  Widget build(BuildContext context) {
    return Material(
      color: Colors.black,
      child: Stack(
        children: [
          Positioned.fill(
            child: _buildPhotoWithFilter(),
          ),
          Positioned(
            left: 0.0,
            right: 0.0,
            bottom: 0.0,
            child: _buildFilterSelector(),
          ),
        ],
      ),
    );
  }

  Widget _buildPhotoWithFilter() {
    return ValueListenableBuilder(
      valueListenable: _filterColor,
      builder: (context, value, child) {
        final color = value as Color;
        return Image.network(
          'https://picsum.photos/id/103/2592/1936.jpg',
          color: color.withOpacity(0.5),
          colorBlendMode: BlendMode.color,
          fit: BoxFit.cover,
        );
      },
    );
  }

  Widget _buildFilterSelector() {
    return FilterSelector(
      onFilterChanged: _onFilterChanged,
      filters: _filters,
    );
  }
}


class FilterSelector extends StatefulWidget {
  const FilterSelector({
    Key key,
     this.filters,
     this.onFilterChanged,
    this.padding = const EdgeInsets.symmetric(vertical: 24.0),
  }) : super(key: key);

  final List<Color> filters;
  final void Function(Color selectedColor) onFilterChanged;
  final EdgeInsets padding;

  
  _FilterSelectorState createState() => _FilterSelectorState();
}

class _FilterSelectorState extends State<FilterSelector> {
  static const _filtersPerScreen = 5;
  static const _viewportFractionPerItem = 1.0 / _filtersPerScreen;

  PageController _controller;
  int _page;

  int get filterCount => widget.filters.length;

  Color itemColor(int index) => widget.filters[index % filterCount];

  
  void initState() {
    super.initState();
    _page = 0;
    _controller = PageController(
      initialPage: _page,
      viewportFraction: _viewportFractionPerItem,
    );
    _controller.addListener(_onPageChanged);
  }

  void _onPageChanged() {
    final page = (_controller.page ?? 0).round();
    if (page != _page) {
      _page = page;
      widget.onFilterChanged(widget.filters[page]);
    }
  }

  void _onFilterTapped(int index) {
    _controller.animateToPage(
      index,
      duration: const Duration(milliseconds: 450),
      curve: Curves.ease,
    );
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scrollable(
      controller: _controller,
      axisDirection: AxisDirection.right,
      physics: PageScrollPhysics(),
      viewportBuilder: (context, viewportOffset) {
        return LayoutBuilder(
          builder: (context, constraints) {
            final itemSize = constraints.maxWidth * _viewportFractionPerItem;
            viewportOffset
              ..applyViewportDimension(constraints.maxWidth)
              ..applyContentDimensions(0.0, itemSize * (filterCount - 1));

            return Stack(
              alignment: Alignment.bottomCenter,
              children: [
                _buildShadowGradient(itemSize),
                _buildCarousel(
                  viewportOffset: viewportOffset,
                  itemSize: itemSize,
                ),
                _buildSelectionRing(itemSize),
              ],
            );
          },
        );
      },
    );
  }

  Widget _buildShadowGradient(double itemSize) {
    return SizedBox(
      height: itemSize * 2 + widget.padding.vertical,
      child: const DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              Colors.transparent,
              Colors.black,
            ],
          ),
        ),
        child: SizedBox.expand(),
      ),
    );
  }

  Widget _buildCarousel({
     ViewportOffset viewportOffset,
     double itemSize,
  }) {
    return Container(
      height: itemSize,
      margin: widget.padding,
      child: Flow(
        delegate: CarouselFlowDelegate(
          viewportOffset: viewportOffset,
          filtersPerScreen: _filtersPerScreen,
        ),
        children: [
          for (var i = 0; i < filterCount; i++)
            FilterItem(
              onFilterSelected: () => _onFilterTapped(i),
              color: itemColor(i),
            ),
        ],
      ),
    );
  }

  Widget _buildSelectionRing(double itemSize) {
    return IgnorePointer(
      child: Padding(
        padding: widget.padding,
        child: SizedBox(
          width: itemSize,
          height: itemSize,
          child: const DecoratedBox(
            decoration: BoxDecoration(
              shape: BoxShape.circle,
              border: Border.fromBorderSide(
                BorderSide(width: 6.0, color: Colors.white),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class CarouselFlowDelegate extends FlowDelegate {
  CarouselFlowDelegate({
     this.viewportOffset,
     this.filtersPerScreen,
  }) : super(repaint: viewportOffset);

  final ViewportOffset viewportOffset;
  final int filtersPerScreen;

  
  void paintChildren(FlowPaintingContext context) {
    final count = context.childCount;
    final size = context.size.width;
    final itemExtent = size / filtersPerScreen;
    final active = viewportOffset.pixels / itemExtent;

    final min = math.max(0, active.floor() - 3).toInt();
    final max = math.min(count - 1, active.ceil() + 3).toInt();

    for (var index = min; index <= max; index++) {
      final itemXFromCenter = itemExtent * index - viewportOffset.pixels;
      final percentFromCenter = 1.0 - (itemXFromCenter / (size / 2)).abs();
      final itemScale = 0.5 + (percentFromCenter * 0.5);
      final opacity = 0.25 + (percentFromCenter * 0.75);

      final itemTransform = Matrix4.identity()
        ..translate((size - itemExtent) / 2)
        ..translate(itemXFromCenter)
        ..translate(itemExtent / 2, itemExtent / 2)
        ..multiply(Matrix4.diagonal3Values(itemScale, itemScale, 1.0))
        ..translate(-itemExtent / 2, -itemExtent / 2);

      context.paintChild(
        index,
        transform: itemTransform,
        opacity: opacity,
      );
    }
  }

  
  bool shouldRepaint(covariant CarouselFlowDelegate oldDelegate) {
    return oldDelegate.viewportOffset != viewportOffset;
  }
}


class FilterItem extends StatelessWidget {
  FilterItem({
    Key key,
     this.color,
    this.onFilterSelected,
  }) : super(key: key);

  final Color color;
  final VoidCallback onFilterSelected;

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onFilterSelected,
      child: AspectRatio(
        aspectRatio: 1.0,
        child: Padding(
          padding: const EdgeInsets.all(8.0),
          child: ClipOval(
            child: Image.network(
              'https://flutter.dev/docs/cookbook/img-files/effects/instagram-buttons/millenial-texture.jpg',
              color: color.withOpacity(0.5),
              colorBlendMode: BlendMode.hardLight,
            ),
          ),
        ),
      ),
    );
  }
}

Example







Reference

[Cookbook - Create a photo filter carousel]

좋은 웹페이지 즐겨찾기