Flutter Snapchat UI Example

Snapchat-like UI using Flutter.

A proof of concept on how to get the Snapchat-style swiping functionality using flutter.

This project includes:

  • a working camera
  • 'play/pause' button, instead of 'take picture'
  • the 'color shading' animation that happens when a user swipes
  • 'flip camera' button
  • lock screen orientation

Here is the demo GIF:

https://github.com/jondelga/flutter_snapchat/raw/master/assets/preview.gif

Step 1: Create Flutter Project

  1. Open your favorite Flutter IDE.
  2. Go to File-->New-->Project to create a new project.

Step 2: Dependencies

We only use minimal dependencies:

dependencies:
  flutter:
    sdk: flutter
  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.0
  camera: ^0.1.2

Step 3: Create Shade

Create a Shade as a statelesswidget:

import 'package:flutter/material.dart';
class Shade extends StatelessWidget {
  final double opacity;
  final bool isLeft;
  Shade({this.opacity, this.isLeft});
  @override
  Widget build(BuildContext context) {
    return new Positioned.fill(
        child: new Opacity(
      opacity: opacity,
      child: new Container(
        color: isLeft ? Colors.lightBlue : Colors.purple,
      ),
    ));
  }
}

Step 4: Create Pager class

Create Pager class with a Left widget, Right widget and PageController class:

import 'package:flutter/material.dart';
class Pager extends StatelessWidget {
  final PageController controller;
  final Widget leftWidget;
  final Widget rightWidget;
  Pager({this.controller, this.leftWidget, this.rightWidget});
  Iterable<Widget> buildPages() {
    final List<Widget> pages = <Widget>[];
    pages.add(_contentWidget(Colors.white, leftWidget));
    pages.add(_contentWidget(Colors.white));
    pages.add(_contentWidget(Colors.white, rightWidget));
    return pages;
  }
  _contentWidget(Color color, [Widget page]) {
    var widgets = <Widget>[];
    widgets.add(new Opacity(
      opacity: 0.0,
      child: new Container(
        color: color,
      ),
    ));
    if (page != null) {
      widgets.add(new Positioned.fill(
        child: new Container(
          margin: new EdgeInsets.fromLTRB(0.0, 80.0, 0.0, 0.0),
          child: page,
          decoration: new ShapeDecoration(
            color: Colors.white,
            shape: new RoundedRectangleBorder(
              borderRadius: new BorderRadius.only(
                topLeft: new Radius.circular(10.0),
                topRight: new Radius.circular(10.0)
              )
            )
          ),
        ),
      ));
    }
    return new Stack(
      children: widgets,
    );
  }
  @override
  Widget build(BuildContext context) {
    return new Positioned.fill(
        child: new Directionality(
          textDirection: TextDirection.ltr,
          child: new PageView(
            controller: controller,
            children: buildPages(),
          ),
        )
      );
  }
}

Step 5: Create Camera Page

Create our Camera page:

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
List<CameraDescription> _cameras;
CameraController _controller;
IconData _cameraLensIcon(CameraLensDirection currentDirection) {
  switch (currentDirection) {
    case CameraLensDirection.back:
      return Icons.camera_front;
    case CameraLensDirection.front:
      return Icons.camera_rear;
    case CameraLensDirection.external:
      return Icons.camera;
  }
  throw new ArgumentError('Unknown lens direction');
}
void playPause() {
  if (_controller != null) {
    if (_controller.value.isStarted) {
      _controller.stop();
    } else {
      _controller.start();
    }
  }
}
Future<Null> _restartCamera(CameraDescription description) async {
    final CameraController tempController = _controller;
    _controller = null;
    await tempController?.dispose();
    _controller = new CameraController(description, ResolutionPreset.high);
    await _controller.initialize();
}
Future<Null> flipCamera() async {
  if (_controller != null) {
    var newDescription = _cameras.firstWhere((desc) {
      return desc.lensDirection != _controller.description.lensDirection;
    });
    await _restartCamera(newDescription);
  }
}
class CameraIcon extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    if (_controller != null) {
      return new Icon(
        _cameraLensIcon(_controller.description.lensDirection),
        color: Colors.white,
      );
    } else {
      return new Container();
    }
  }
}
class CameraHome extends StatefulWidget {
  @override
  _CameraHomeState createState() => new _CameraHomeState();
}
class _CameraHomeState extends State<CameraHome> with WidgetsBindingObserver {
  bool opening = false;
  String imagePath;
  int pictureCount = 0;
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    availableCameras().then((cams) {
      _cameras = cams;
      _controller = new CameraController(_cameras[1], ResolutionPreset.high);
      _controller.initialize()
        .then((_) {
          if (!mounted) {
            return;
          }
          setState(() {});
        });
    });
  }
  @override
  void dispose() {
    _controller?.dispose();
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      var description = _cameras.firstWhere((desc) {
        return desc.lensDirection == _controller.description.lensDirection;
      });
      _restartCamera(description)
        .then((_) { 
          setState(() {});
        });
    }
  }
  @override
  Widget build(BuildContext context) {
    var screenDimension = MediaQuery.of(context).size;
    final List<Widget> columnChildren = <Widget>[];
    if (_controller != null && _controller.value.initialized) {
      columnChildren.add(new Expanded(
        child: new FittedBox(
          fit: BoxFit.fitHeight,
          alignment: AlignmentDirectional.center,
          child: new Container(
            width: screenDimension.width,
            height: screenDimension.height * _controller.value.aspectRatio,
            child: new CameraPreview(_controller),
          )
        )
      ));
    } else {
      columnChildren.add(new Center(
        child: new Directionality(
          textDirection: TextDirection.ltr,
          child: new Icon(Icons.question_answer)
        )
      ));
    }
    return new Column(
      children: columnChildren,
    );
  }
  Widget playPauseButton() {
    return new Padding(
      padding: const EdgeInsets.only(top: 25.0),
      child: new FlatButton(
        onPressed: () {
          setState(() {
            if (_controller.value.isStarted) {
              _controller.stop();
            } else {
              _controller.start();
            }
          });
        },
        child: new Icon(
          _controller.value.isStarted ? Icons.pause : Icons.play_arrow
        ),
      ),
    );
  }
}

Step 6: Create Circle Button

Create a custom circle button widget as shown below:

import 'dart:math';
import 'package:flutter/material.dart';
import 'dart:ui' show lerpDouble;
import 'camera_page.dart';
class ControlsLayer extends StatelessWidget {
  final double offset;
  final Function onTap;
  final _ShadowTween shadowTween;
  final _TakePictureTween buttonTween;
  final CameraIcon cameraIcon;
  final Function onCameraTap;
  ControlsLayer({this.offset, this.onTap, this.cameraIcon, this.onCameraTap}) : 
    this.buttonTween = new _TakePictureTween(
      new _TakePicture( 70.0, 100.0, onTap: onTap,),
      new _TakePicture( 50.0, 80.0,)
    ),
    this.shadowTween = new _ShadowTween(new _Shadow(-290.0), new _Shadow(-150.0));
  @override
  Widget build(BuildContext context) {
    return new Stack(
      children: <Widget>[
        shadowTween.lerp(offset),
        buttonTween.lerp(offset),
        new _Controls(cameraIcon, onCameraTap)
      ],
    );
  }
}
class _Controls extends StatelessWidget {
  final CameraIcon cameraIcon;
  final Function onCameraTap;
  _Controls(this.cameraIcon, this.onCameraTap);
  @override
  Widget build(BuildContext context) {
    return new Positioned(
      top: 35.0,
      left: 20.0,
      child: new SizedBox(
        width: 20.0,
        height: 40.0,
        child: new GestureDetector(
          onTap: onCameraTap,
          child: cameraIcon,
        ),
      ),
    );
  }
}
class _TakePicture extends StatelessWidget {
  final double bottom;
  final double diameter;
  final Function onTap;
  _TakePicture(this.bottom, this.diameter, {this.onTap});
  @override
  Widget build(BuildContext context) {
    return new Positioned(
      bottom: bottom,
      left: MediaQuery.of(context).size.width / 2 - 50,
      child: new SizedBox(
        width: 100.0,
        height: 100.0,
        child: new Container(
          alignment: Alignment.bottomCenter,
          child: new SizedBox(
            width: diameter,
            height: diameter,
            child: new GestureDetector(
              onTap: onTap,
              child: new Container(
                decoration: new ShapeDecoration(
                  shape: new CircleBorder(
                    side: new BorderSide(
                      width: 5.0, color: Colors.white
                    )
                  )
                ),
              ),
            ),
          ),
        )
      )
    );
  }
  static _TakePicture lerp(_TakePicture begin, _TakePicture end, double t) {
    return new _TakePicture(
      lerpDouble(begin.bottom, end.bottom, t),
      lerpDouble(begin.diameter, end.diameter, t),
      onTap: begin.onTap
    );
  }
}
class _TakePictureTween extends Tween<_TakePicture> {
  _TakePictureTween(_TakePicture begin, _TakePicture end)
    : super(begin: begin, end: end);
  _TakePicture lerp(double t) => _TakePicture.lerp(begin, end, t);
}
class _Shadow extends StatelessWidget {
  final double bottom;
  _Shadow(this.bottom);
  final double shadowSize = 250.0;
  @override
  Widget build(BuildContext context) {
    return new Positioned(
      bottom: bottom,
      left: MediaQuery.of(context).size.width / 2 - (shadowSize / 2),
      child: new Transform.rotate(
        angle: PI / 4,
        child: new Container(
          width: shadowSize,
          height: shadowSize,
          decoration: new BoxDecoration(boxShadow: <BoxShadow>[
            new BoxShadow(color: Colors.black12, blurRadius: 20.0)
          ], borderRadius: new BorderRadius.all(new Radius.circular(40.0))),
        ),
      ),
    );
  }
  static _Shadow lerp(_Shadow begin, _Shadow end, double t) {
    return new _Shadow(lerpDouble(begin.bottom, end.bottom, t));
  }
}
class _ShadowTween extends Tween<_Shadow> {
  _ShadowTween(_Shadow begin, _Shadow end) : super(begin: begin, end: end);
  @override
  _Shadow lerp(double t) => _Shadow.lerp(begin, end, t);
}

Step 7: Create Main class

Create our main.dart class as shown below:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:ui';
import 'camera_page.dart';
import 'pager.dart';
import 'circle_button.dart';
import 'shade.dart';
class EntryPoint extends StatefulWidget {
  _EntryPointState createState() => new _EntryPointState();
}
class _EntryPointState extends State<EntryPoint> {
  final PageController pagerController =
      new PageController(keepPage: true, initialPage: 1);
  double buttonDiameter = 100.0;
  double offsetRatio = 0.0;
  double offsetFromOne = 0.0;
  bool onPageView(ScrollNotification notification) {
    if (notification.depth == 0 && notification is ScrollUpdateNotification) {
      setState(() {
        offsetFromOne = 1.0 - pagerController.page;
        offsetRatio = offsetFromOne.abs();
      });
    }
    return false;
  }
  @override
  Widget build(BuildContext context) {
    return new MediaQuery(
      data: new MediaQueryData.fromWindow(window),
      child: new Directionality(
        textDirection: TextDirection.ltr,
        child: new Scaffold(
          body: new Stack(
            children: <Widget>[
              new CameraHome(),
              new Shade(
                opacity: offsetRatio,
                isLeft: offsetFromOne > 0,
              ),
              new NotificationListener<ScrollNotification>(
                onNotification: onPageView,
                child: new Pager(
                  controller: pagerController,
                  leftWidget: new ItemList(
                    amount: 30,
                  ),
                  rightWidget: new Text('right'),
                )
              ),
              new ControlsLayer(
                offset: offsetRatio,
                onTap: () {
                  playPause();
                },
                cameraIcon: new CameraIcon(),
                onCameraTap: () async {
                  await flipCamera();
                  setState(() {});
                },
              )
            ],
          ),
        )
      )
    );
  }
}
class ItemList extends StatelessWidget {
  final int amount;
  ItemList({this.amount});
  @override
  Widget build(BuildContext context) {
    var children = <Widget>[];
    for (int i = 0; i < amount; i++) {
      children.add(new ListTile(
        title: new Text('tile $i'),
      ));
    }
    return new ListView(
      children: children,
    );
  }
}
void main() {
  SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp])
    .then((_) {
      runApp(new EntryPoint());
    });
}

Run

To run the project:

flutter packages get
flutter run

Reference

Download the code here.
Follow code author here.