Mimic iOS contact form AppBar

2020-06-23 07:16发布

问题:

I'm trying to mimic iOS contact form app bar.

expanded

collapsed

Here is where I get so far

Main Screen

class CompanyScreen extends StatefulWidget {
  @override
  _CompanyScreenState createState() => _CompanyScreenState();
}

class _CompanyScreenState extends State<CompanyScreen> {
  @override
  Widget build(BuildContext context) {

    return Scaffold(
      body: CustomScrollView(
        slivers: <Widget>[
          SliverPersistentHeader(
            pinned: true,
            floating: true,
            delegate: SafeAreaPersistentHeaderDelegate(
                expandedHeight: 200,
                flexibleSpace:
                    SafeArea(child: Image.asset('assets/images/user.png'))),
          ),
          SliverList(
            delegate: SliverChildListDelegate([
              TextField(),
            ]),
          )
        ],
      ),
    );
  }
}

SliverHeader

class SafeAreaPersistentHeaderDelegate extends SliverPersistentHeaderDelegate {
  final Widget title;

  final Widget flexibleSpace;

  final double expandedHeight;

  SafeAreaPersistentHeaderDelegate(
      {this.title, this.flexibleSpace, this.expandedHeight});

  @override
  Widget build(
      BuildContext context, double shrinkOffset, bool overlapsContent) {
    final Widget appBar = FlexibleSpaceBar.createSettings(
      minExtent: minExtent,
      maxExtent: maxExtent,
      currentExtent: max(minExtent, maxExtent - shrinkOffset),
      toolbarOpacity: 1,
      child: AppBar(
          actions: <Widget>[
            Container(
              height: 60,
              child: FlatButton(
                child: Text('Done'),
              ),
            )
          ],
          backgroundColor: Colors.blue,
          automaticallyImplyLeading: false,
          title: title,
          flexibleSpace: (title == null && flexibleSpace != null)
              ? Semantics(child: flexibleSpace, header: true)
              : flexibleSpace,
          centerTitle: true,
          toolbarOpacity: 1,
          bottomOpacity: 1.0),
    );
    return appBar;
  }

  @override
  double get maxExtent => expandedHeight;

  @override
  double get minExtent => 80;

  @override
  bool shouldRebuild(SafeAreaPersistentHeaderDelegate old) {
    if (old.flexibleSpace != flexibleSpace) {
      return true;
    }
    return false;
  }
}

UPDATE: It all works but I have a problem add the text under the image (Add Photo) and make that text disappear when collapsed. With this solution, if I wrap the image into a column then image expands overflow and doesn't scale.

Requirements:

  1. AppBar and the flex area must be in safe area

  2. Widget with image must have text at the bottom which can be changed dynamically (Add image or Change image) and it must be clickable

  3. The text under the image area must disappear when flex area is collapsed with some transition

  4. Ability to add title in app bar lined up with action buttons

  5. When title in app bar is provided then flex area should scale bellow the title, if not flex area should scale into the title area as on the above image

Any help with this greatly appreciated

回答1:

I gave it a try.. I'm not an expert on slivers so this solution might not be perfect. I have taken your code as starting point. The column seems to deactivate all scaling so I scaled all manually.

here is your app bar

UPDATE I have tweaked it a little so it feels more like iOS app bar plus I've added extra feature

import 'dart:math';

import 'package:flutter/material.dart';

double _defaultTextHeight = 14;
double _defaultTextPadding = 5;
double _defaultAppBarHeight = 60;
double _defaultMinAppBarHeight = 40;
double _unknownTextValue = 1;

class AppBarSliverHeader extends SliverPersistentHeaderDelegate {
  final String title;
  final double expandedHeight;
  final double safeAreaPadding;
  final Widget flexibleImage;
  final double flexibleSize;
  final String flexibleTitle;
  final double flexiblePadding;
  final bool flexToTop;
  final Function onTap;
  final Widget rightButton;
  final Widget leftButton;

  AppBarSliverHeader(
      {this.title,
      this.onTap,
      this.flexibleImage,
      @required this.expandedHeight,
      @required this.safeAreaPadding,
      this.flexibleTitle = '',
      this.flexToTop = false,
      this.leftButton,
      this.rightButton,
      this.flexibleSize = 30,
      this.flexiblePadding = 4});

  double _textPadding(double shrinkOffset) {
    return _defaultTextPadding * _scaleFactor(shrinkOffset);
  }

  double _widgetPadding(double shrinkOffset) {
    double offset;
    if (title == null) {
      offset = _defaultMinAppBarHeight * _scaleFactor(shrinkOffset);
    } else {
      if (flexToTop) {
        offset = _defaultAppBarHeight * _scaleFactor(shrinkOffset);
      } else {
        offset = (_defaultAppBarHeight - _defaultMinAppBarHeight) *
                _scaleFactor(shrinkOffset) +
            _defaultMinAppBarHeight;
      }
    }
    return offset;
  }

  double _topOffset(double shrinkOffset) {
    double offset;
    if (title == null) {
      offset = safeAreaPadding +
          (_defaultMinAppBarHeight * _scaleFactor(shrinkOffset));
    } else {
      if (flexToTop) {
        offset = safeAreaPadding +
            (_defaultAppBarHeight * _scaleFactor(shrinkOffset));
      } else {
        offset = safeAreaPadding +
            ((_defaultAppBarHeight - _defaultMinAppBarHeight) *
                _scaleFactor(shrinkOffset)) +
            _defaultMinAppBarHeight;
      }
    }

    return offset;
  }

  double _calculateWidgetHeight(double shrinkOffset) {
    double actualTextHeight = _scaleFactor(shrinkOffset) * _defaultTextHeight +
        _textPadding(shrinkOffset) +
        _unknownTextValue;

    final padding = title == null
        ? (2 * flexiblePadding)
        : flexToTop ? (2 * flexiblePadding) : flexiblePadding;

    final trueMinExtent = minExtent - _topOffset(shrinkOffset);

    final trueMaxExtent = maxExtent - _topOffset(shrinkOffset);

    double minWidgetSize =
        trueMinExtent - padding;

    double widgetHeight =
        ((trueMaxExtent - actualTextHeight) - shrinkOffset) - padding;

    return widgetHeight >= minWidgetSize ? widgetHeight : minWidgetSize;
  }

  double _scaleFactor(double shrinkOffset) {
    final ratio = (maxExtent - minExtent) / 100;
    double percentageHeight = shrinkOffset / ratio;
    double limitedPercentageHeight =
        percentageHeight >= 100 ? 100 : percentageHeight;
    return 1 - (limitedPercentageHeight / 100);
  }

  Widget _builtContent(BuildContext context, double shrinkOffset) {
    _topOffset(shrinkOffset);
    return SafeArea(
      bottom: false,
      child: Semantics(
        child: Padding(
          padding: title == null
              ? EdgeInsets.symmetric(vertical: flexiblePadding)
              : flexToTop
                  ? EdgeInsets.symmetric(vertical: flexiblePadding)
                  : EdgeInsets.only(bottom: flexiblePadding),
          child: GestureDetector(
            onTap: onTap,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                LimitedBox(
                    maxWidth: _calculateWidgetHeight(shrinkOffset),
                    maxHeight: _calculateWidgetHeight(shrinkOffset),
                    child: Container(
                      decoration: BoxDecoration(
                          borderRadius: BorderRadius.all(Radius.circular(
                              _calculateWidgetHeight(shrinkOffset))),
                          color: Colors.white),
                      child: ClipRRect(
                        borderRadius: BorderRadius.circular(
                            _calculateWidgetHeight(shrinkOffset)),
                        child: flexibleImage,
                      ),
                    )),
                Padding(
                  padding: EdgeInsets.only(top: _textPadding(shrinkOffset)),
                  child: Text(
                    flexibleTitle,
                    textScaleFactor: _scaleFactor(shrinkOffset),
                    style: TextStyle(
                        fontSize: _defaultTextHeight,
                        color: Colors.white
                            .withOpacity(_scaleFactor(shrinkOffset)), height: 1),
                  ),
                )
              ],
            ),
          ),
        ),
        button: true,
      ),
    );
  }

  @override
  Widget build(
      BuildContext context, double shrinkOffset, bool overlapsContent) {
    final Widget appBar = FlexibleSpaceBar.createSettings(
      minExtent: minExtent,
      maxExtent: maxExtent,
      currentExtent: max(minExtent, maxExtent - shrinkOffset),
      toolbarOpacity: 1,
      child: AppBar(
          actions: <Widget>[rightButton == null ? Container() : rightButton],
          leading: leftButton == null ? Container() : leftButton,
          backgroundColor: Colors.blue,
          automaticallyImplyLeading: false,
          title: title != null
              ? Text(
                  title,
                  style: TextStyle(
                      color: flexToTop
                          ? Colors.white.withOpacity(_scaleFactor(shrinkOffset))
                          : Colors.white),
                )
              : null,
          flexibleSpace: Padding(
            padding: EdgeInsets.only(top: _widgetPadding(shrinkOffset)),
            child: _builtContent(context, shrinkOffset),
          ),
          centerTitle: true,
          toolbarOpacity: 1,
          bottomOpacity: 1.0),
    );
    return appBar;
  }

  @override
  double get maxExtent => expandedHeight + safeAreaPadding;

  @override
  double get minExtent => title == null
      ? _defaultAppBarHeight + safeAreaPadding
      : flexToTop
          ? _defaultAppBarHeight + safeAreaPadding
          : _defaultAppBarHeight + safeAreaPadding + flexibleSize;

  @override
  bool shouldRebuild(AppBarSliverHeader old) {
    if (old.flexibleImage != flexibleImage) {
      return true;
    }
    return false;
  }
}

and here is usage

Scaffold(
      body: CustomScrollView(
        slivers: <Widget>[
          SliverPersistentHeader(
            pinned: true,
            floating: true,
            delegate: AppBarSliverHeader(
                expandedHeight: 250,
                safeAreaPadding: MediaQuery.of(context).padding.top,
                title: 'New Contact',
                flexibleImage: Image.asset('assets/images/avatar.png'),
                flexibleTitle: 'Add Image',
                flexiblePadding: 6,
                flexibleSize: 50,
                flexToTop: true,
                onTap: () {
                  print('hello');
                },
                leftButton: IconButton(
                  icon: Text('Cancel'),
                  iconSize: 60,
                  padding: EdgeInsets.zero,
                  onPressed: () {},
                ),
                rightButton: IconButton(
                  icon: Text('Done'),
                  iconSize: 60,
                  padding: EdgeInsets.zero,
                  onPressed: () {},
                )),
          ),
          SliverList(
            delegate: SliverChildListDelegate([
              TextField(),
            ]),
          )
        ],
      ),
    );

There are some things which took me by surprise as well. First is text size. It seems like text size is not an actual text size so I've added _unknownTextValue there for compensation. Also even if text size is set to 0 then the Text widget has still 1px size so I've compensated that in commented code. Another thing is I wanted to use CircularAvatar for the image but apparently the CircularAvatar widget has built in animation when changing the size which interfere with app bar animation so I've built custom avatar.

UPDATE: To make actual text height same as font size, I have added height property 1 to TextStyle. It seems to work however there is still occasional overflow on the textfield of up to 1px so I've kept _unknownTextValue at 1px

As I said I'm not sliver expert so there might be a better solutions out there so I would suggest you to wait for other answers

NOTE: I only tested it on 2 iOS devices so you should test further to use it

With Title

Without Title

With Title and flexToTop activated



标签: flutter dart