Giter VIP home page Giter VIP logo

blog's People

Contributors

smallstonesk avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

在Flutter中使用自定义Icon

1. 前言

Flutter作为时下最流行的技术之一,凭借其出色的性能以及抹平多端的差异优势,早已引起大批技术爱好者的关注,甚至一些闲鱼美团腾讯等大公司均已投入生产使用。虽然目前其生态还没有完全成熟,但身靠背后的Google加持,其发展速度已经足够惊人,可以预见将来对Flutter开发人员的需求也会随之增长。

无论是为了技术尝鲜还是以后可能的工作机会,都9102年了,作为一个前端开发者,似乎没有理由不去尝试它。正是带着这样的心理,笔者也开始学习Flutter,同时建了一个用于练习的仓库,后续所有代码都会托管在上面,欢迎star,一起学习。

今天要分享的内容其实非常简单,我们都知道Flutter内置了一套Material Design风格的Icon图标,但对于一个成熟的App而言,通常情况下还是远远不够的。为此,我们需要在项目中引入自定义的Icon图标

本文就将以Ant Design图标库为例,介绍如何在Flutter中引入自定义图标。

2. 准备工作:字体文件

正所谓“巧妇难为无米之炊”,要想引入自定义图标,首先我们得准备好图标字体文件(.ttf后缀)。对于大公司而言,找视觉同学切就可以了。但如果是自己做的业余项目或者没有资源的时候,我们可以上阿里巴巴矢量图标库pick自己心仪的图标。

这里就以Ant Design官方图标库为例(一共有600个图标),通过以下操作,我们将图标字体文件加入到项目中:

添加购物车 --> 点击购物车 --> 下载代码 --> 解压 --> 拷贝至项目(可重命名)

步骤1

步骤2

步骤3

3. 声明自定义字体

仅仅将字体文件复制到项目中还不够,我们需要通过声明的方式来告诉Flutter有新字体可用。打开项目根目录下的pubspec.yaml文件,找到fonts这一段:

To add custom fonts to your application, add a fonts section here, in this "flutter" section. Each entry in this list should have a "family" key with the font family name, and a "fonts" key with a list giving the asset and other descriptors for the font.

注释就是让我们在该段文字下方添加自定义字体的声明,结合其注释掉的例子和当前的项目目录,我们可以这样配置:

项目工程目录结构
.
├── README.md
├── android
│   └── app
├── assets
│   └── fonts
│       └── AntdIcons.ttf
├── flutter_training_app.iml
├── ios
│   └── Flutter
├── lib
│   └── main.dart
├── pubspec.lock
└── pubspec.yaml

字体声明
fonts:
  - family: AntdIcons
    fonts:
      - asset: assets/fonts/AntdIcons.ttf

注意: 配置完之后,一定要执行flutter packages get命令以及rebuild项目,否则字体文件无法使用。

4. 编写自定义的IconData

其实到目前为止,我们已经可以使用刚刚下载的图标了,就像下面代码这样:

Icon(
  IconData(0xe77d, fontFamily: 'AntdIcons'),
  size: 20,
  color: Colors.black
)

其中fontFamily的值'AntdIcons'就是我们刚才声明的新字体,但是代码中的0xe77d数值是哪来的呢?再次打开之前下载解压之后的文件夹,其中有一个demo_index.html文件,在浏览器中打开它我们可以看到下面的画面:

Unicode-类型对照表

Unicode这个Tab下,我们可以看到它贴心地给出了所有图标的TypeUnicode码对照关系。所以理论上来说,我们想用哪个图标,只要copy其Unicode码到代码中就可以了。

不过,这种做法显然不是很友好。首先,我们每次使用Icon之前都要从这张关系表中查找;其次,你确定下次代码中看到这串数字是对应什么图标吗?所以,我们需要更优雅的方法来管理自定义图标。

其实做法也简单,我们可以创建一个自定义图标的类:

class AntdIcons {
  static const IconData checkCircle = IconData(0xe77d, fontFamily: 'AntdIcons');
  static const IconData CI = IconData(0xe77e, fontFamily: 'AntdIcons');
  static const IconData Dollar = IconData(0xe77f, fontFamily: 'AntdIcons');
  ...
}

然后使用方法就变成了:

Icon(
  AntdIcons.checkCircle,
  size: 20,
  color: Colors.black
)

以上代码完全等同于前面直接使用Unicode码的效果。不过要想用上所有的图标,我们还得丰富AntdIcons这个类。为此,可以写上一段小脚本,在demo_index.html浏览器窗口的控制台中运行就能得到定义IconData的代码:

function camelCase(str) {
  return str.replace(/[ -]+(\w)/g, (match, char) => char.toUpperCase());
}

function makeCode({name, code}) {
  return `static const IconData ${camelCase(name)} = IconData(0${code.substr(2, 5)}, fontFamily: 'antd-icons');\n`;
}

Array
  .from(document.querySelectorAll('.unicode .dib'))
  .map(element => {
    return {
      name: element.querySelector('.name').innerText,
      code: element.querySelector('.code-name').innerText
    };
  })
  .map(makeCode)
  .join('\n');

PS:输出结果中可能由于图标作者自己命名不规范而导致个别的小错误,手动修改即可,完整文件可以看这里

接下来,就是愉快玩耍的时候啦~~~

5. 总结

本文通过一个实际的Ant Design图标例子,详细地介绍了如何在Flutter中引入自定义图标,希望可以帮助到你哦~

本文所有代码托管在这儿,欢迎一起学习~

RN自定义组件封装 - 拖拽选择日期的日历

1. 前言

由于最近接到一个需要支持拖拽选择日期的日历需求,做出来感觉体验和效果都还不错,所以今天想跟大家分享一下封装这个日历组件的过程。

2. 调研开始

正所谓“磨刀不误砍柴工”,既然要做一个日历,那么先让我们来看看最终想要做成什么样:

由于之前吃过RN在安卓上性能表现不佳的亏,深深地怀疑这东西做出来能在安卓上跑么,尤其是日期要实时地随着手指滑动的位置发生变化。还有这牵涉到了手势系统,之前又没捣鼓过,谁知道有没有什么天坑在等着我。。。

唉,不管了,先把最简单的样式实现了再考虑这些吧~

But! 正所谓“巧妇难为无米之炊”,没有相应的日历数据,怎么画日历!So, let's do it first.

2.1 日历数据

Q1:如何确定日历要渲染哪些天的数据?

仔细观察先前的示意图,我们可以发现日历中有些天是暗的,有些是高亮的。也就是说日历上所渲染出来的这些格子,是有available/unavailable区别的。为此,我们可以支持两种方式通过props传入:

  1. 调用方指定fullDateRange和availableDateRange。fullDateRange是起始月份第一天到终止月份最后一天,availableDateRange是用户可选范围第一天到最后一天。
  2. 调用方指定maxDays。也就是今天是availableDateRange的第一天,而今天+maxDays是availableDateRange的最后一天;fullDateRange则是今天所在月份的第一天到今天+maxDays所在月份的最后一天。

理清了思路,我们来看看代码实现:

export class DraggableCalendar extends Component {

  constructor(props) {
    super(props);
    this.state = {
      calendarData: this._genCalendarData()
    };
  }

  _genCalendarData({fullDateRange, availableDateRange, maxDays}) {

    let startDate, endDate, availableStartDate, availableEndDate;

    // if the exact dateRange is given, use availableDateRange; or render [today, today + maxDays]
    if(fullDateRange) {
      [startDate, endDate] = fullDateRange;
      [availableStartDate, availableEndDate] = availableDateRange;
    } else {
      const today = Helper.parseDate(new Date(), 'yyyy-MM-dd');
      availableStartDate = today;
      availableEndDate = Helper.addDay(today, maxDays);
      startDate = new Date(new Date(today).setDate(1));
      endDate = Helper.getLastDayOfMonth(availableEndDate.getFullYear(), availableEndDate.getMonth());
    }

    // TODO: realize _genDayData function
    return this._genDayData({startDate, endDate, availableStartDate, availableEndDate});
  }

  // ...
}

Q2:calendarData的结构怎么设计比较好?

经过上一步,我们已经知晓了哪些day是需要渲染的,接下来我们再看看数据结构应该怎么设计:

  1. 首先,每个月份的数据其实是相似的,无非就是包括了有哪些天。因此,我们可以用一个map对象来存储,key就是year-month组成的字符串,value就是这个月份相对应的数据。这样既能利用年月作为特殊标志符彼此区分,还能根据给定的年月信息快速定位到相应的days数据。
  2. 再来看day的数据结构,我们可以先给它定义几个基础属性:date、available、status。其中,status代表该日期当前的状态,主要是用以区分用户在拖拽操作日历时,有没有选中该日期。

我们再来看看相应的代码应该如何实现:

const DAY_STATUS = {
  NONE: 0,
  SINGLE_CHOSEN: 1,
  RANGE_BEGIN_CHOSEN: 2,
  RANGE_MIDDLE_CHOSEN: 3,
  RANGE_END_CHOSEN: 4
};

_genDayData({startDate, endDate, availableStartDate, availableEndDate}) {

  let result = {}, curDate = new Date(startDate);

  while(curDate <= endDate) {

    // use `year-month` as the unique identifier
    const identifier = Helper.formatDate(curDate, 'yyyy-MM');

    // if it is the first day of a month, init it with an array
    // Note: there are maybe several empty days at the first of each month
    if(!result[identifier]) {
      result[identifier] = [...(new Array(curDate.getDay() % 7).fill({}))];
    }

    // save each day's data into result
    result[identifier].push({
      date: curDate,
      status: DAY_STATUS.NONE,
      available: (curDate >= availableStartDate && curDate <= availableEndDate)
    });

    // curDate + 1
    curDate = Helper.addDay(curDate, 1);
  }

  // there are several empty days in each month
  Object.keys(result).forEach(key => {
    const len = result[key].length;
    result[key].push(...(new Array((7 - len % 7) % 7).fill({})));
  });

  return result;
}

生成日历数据就这样大功告成啦,貌似还挺容易的嘛~ 我们来打个log看看长什么样:

2.2 日历样式

其实样式这个环节,倒是最容易的,主要是对日历的内容进行合适的拆解。

  1. 首先,我们可以拆分为renderHeader和renderBody。其中,header是上方的周几信息,body则是由多个月份组成的主体内容。
  2. 其次,每个月份由又可以拆分成renderMonthHeader和renderMonthBody。其中,monthHeader展示相应的年月信息,monthBody则是这个月的日期信息。(PS: 有一点可以取巧的是monthBody部分,我们可以用FlatList的numColumns这个属性实现,只要设置成7就行。)
  3. 最后,我们可以用renderDay来渲染每个日期的信息。需要注意的是,每个Day可能有5种不同的状态(NONE, SINGLE_CHOSEN, RANGE_BEGIN_CHOSEN, RANGE_MIDDLE_CHOSEN, RANGE_END_CHOSEN),所以需要不同的相应样式来对应。

除此之外,还有一点就是一定要考虑该日历组件的可扩展性,样式方面肯定是可以让调用方可自定义啦。为此,代码方面我们可以这么写:

export class DraggableCalendar extends Component {

  // ...

  _renderHeader() {
    const {headerContainerStyle, headerTextStyle} = this.props;
    return (
      <View style={[styles.headerContainer, headerContainerStyle]}>
        {['日', '一', '二', '三', '四', '五', '六'].map(item => (
          <Text key={item} style={[styles.headerText, headerTextStyle]}>{item}</Text>
        ))}
      </View>
    );
  }

  _renderBody() {
    const {calendarData} = this.state;
    return (
      <ScrollView>
        {Object
          .keys(calendarData)
          .map((key, index) => this._renderMonth({identifier: key, data: calendarData[key], index}))
        }
      </ScrollView>
    );
  }

  _renderMonth({identifier, data, index}) {
    return [
      this._renderMonthHeader({identifier}),
      this._renderMonthBody({identifier, data, index})
    ];
  }

  _renderMonthHeader({identifier}) {
    const {monthHeaderStyle, renderMonthHeader} = this.props;
    const [year, month] = identifier.split('-');
    return (
      <View key={`month-header-${identifier}`}>
        {renderMonthHeader ?
          renderMonthHeader(identifier) :
          <Text style={[styles.monthHeaderText, monthHeaderStyle]}>{`${parseInt(year)}${parseInt(month)}月`}</Text>
        }
      </View>
    );
  }

  _renderMonthBody({identifier, data, index}) {
    return (
      <FlatList
        ref={_ => this._refs['months'][index] = _}
        data={data}
        numColumns={7}
        bounces={false}
        key={`month-body-${identifier}`}
        keyExtractor={(item, index) => index}
        renderItem={({item, index}) => this._renderDay(item, index)}
      />
    );
  }

  _renderDay(item, index) {
    const {
      renderDay, dayTextStyle, selectedDayTextStyle, dayContainerStyle,
      singleDayContainerStyle, beginDayContainerStyle, middleDayContainerStyle, endDayContainerStyle
    } = this.props;
    let usedDayTextStyle = [styles.dayText, dayTextStyle];
    let usedDayContainerStyle = [styles.dayContainer, dayContainerStyle];
    if(item.status !== DAY_STATUS.NONE) {
      const containerStyleMap = {
        1: [styles.singleDayContainer, singleDayContainerStyle],
        2: [styles.beginDayContainer, beginDayContainerStyle],
        3: [styles.middleDayContainer, middleDayContainerStyle],
        4: [styles.endDayContainer, endDayContainerStyle]
      };
      usedDayTextStyle.push(styles.selectedDayText, selectedDayTextStyle);
      usedDayContainerStyle.push(...(containerStyleMap[item.status] || {}));
    }
    return (
      <View key={`day-${index}`} style={{flex: 1}}>
        {renderDay ?
          renderDay(item, index) :
          <View style={usedDayContainerStyle}>
            {item.date && (
              <Text style={[...usedDayTextStyle, !item.available && {opacity: .6}]}>
                {item.date.getDate()}
              </Text>
            )}
          </View>
        }
      </View>
    );
  }

  render() {
    const {style} = this.props;
    return (
      <View style={[styles.container, style]}>
        {this._renderHeader()}
        {this._renderBody()}
      </View>
    );
  }
}

2.3 实现拖拽

呼~ 长吁一口气,万里长征终于迈出了第一步,接下来就是要实现拖拽了。而要实现拖拽,我们可以通过大致以下流程:

  1. 获得所有日历中所有日期的布局信息,和手指触摸的实时坐标信息;
  2. 根据手指当前所在的坐标信息,计算出手指落在哪个日期上,也就是当前选中的日期;
  3. 比较前后的选中日期信息,如果不同,更新state,触发render重新渲染。

为此,我们来逐一解决各个问题:

2.3.1 获取相关布局和坐标信息

获取相关布局:
在RN中,有两种方法可以获取一个元素的布局信息。一个是onLayout,还有一个就是UIManager.measure。讲道理,两种方法都能实现我们的需求,但是通过UIManager.measure,我们这里的代码可以更优雅。具体代码如下:

export class DraggableCalendar extends Component {

  constructor(props) {
    // ...
    this._monthRefs = [];
    this._dayLayouts = {};
  }

  componentDidMount() {
    Helper.waitFor(0).then(() => this._genLayouts());
  }

  _getRefLayout(ref) {
    return new Promise(resolve => {
      UIManager.measure(findNodeHandle(ref), (x, y, width, height, pageX, pageY) => {
        resolve({x, y, width, height, pageX, pageY});
      });
    });
  }

  _genDayLayout(identifier, layout) {

    // according to the identifier, find the month data from calendarData
    const monthData = this.state.calendarData[identifier];

    // extract info from layout, and calculate the width and height for each day item
    const {x, y, width, height} = layout;
    const ITEM_WIDTH = width / 7, ITEM_HEIGHT = height / (monthData.length / 7);

    // calculate the layout for each day item
    const dayLayouts = {};
    monthData.forEach((data, index) => {
      if(data.date) {
        dayLayouts[Helper.formatDate(data.date, 'yyyy-MM-dd')] = {
          x: x + (index % 7) * ITEM_WIDTH,
          y: y + parseInt(index / 7) * ITEM_HEIGHT,
          width: ITEM_WIDTH,
          height: ITEM_HEIGHT
        };
      }
    });

    // save dayLayouts into this._layouts.days
    Object.assign(this._dayLayouts, dayLayouts);
  }

  _genLayouts() {
    // after rendering scrollView and months, generates the layout params for each day item.
    Promise
      .all(this._monthRefs.map(ref => this._getRefLayout(ref)))
      .then((monthLayouts) => {
        // according to the month's layout, calculate each day's layout
        monthLayouts.forEach((monthLayout, index) => {
          this._genDayLayout(Object.keys(this.state.calendarData).sort()[index], monthLayout);
        });
        console.log(Object.keys(this._dayLayouts).map(key => this._dayLayouts[key].y));
      });
  }

  _renderMonthBody({identifier, data, index}) {
    return (
      <FlatList
        ref={_ => this._monthRefs[index] = _}
        data={data}
        numColumns={7}
        bounces={false}
        key={`month-body-${identifier}`}
        keyExtractor={(item, index) => index}
        renderItem={({item, index}) => this._renderDay(item, index)}
      />
    );
  }

  // ...
}

通过给UIManager.measure封装一层promise,我们可以巧妙地利用Promise.all来知道什么时候所有的month元素都已经渲染完毕,然后可以进行下一步的dayLayouts计算。但是,如果使用onLayout方法就不一样了。由于onLayout是异步触发的,所以没法保证其调用的先后顺序,更是不知道什么时候所有的month都渲染完毕了。除非,我们再额外加一个计数器,当onLayout触发的次数(计数器的值)等于month的个数,这样才能知道所有month渲染完毕。不过相比于前一种方法,肯定是前一种更优雅啦~

获取手指触摸的坐标信息:
重头戏终于要来啦!在RN中,有一个手势系统封装了丰富的手势相关操作,相关文档可以戳这里

首先我们来思考这么个问题,由于日历的内容是用ScrollView包裹起来的,因此我们正常的上下拖动操作会导致ScrollView内容上下滚动。那么问题就来了,我们应该怎么区分这个上下拖动操作,是应该让内容上下滚动,还是选中不同的日历范围呢?

在这里,我采用的解决方案是用两个透明的View盖在ScrollView上层,然后把手势处理系统加在这层View上。由于手指是触摸在View上,并不会导致ScrollView滚动,因此完美地规避了上面这个问题。

不过,如果用这种方法会有另外一个问题。因为透明的View是采用的绝对定位布局,left和top值是当前选中日期的坐标信息。但是当ScrollView上下发生滚动时,这层透明View也要跟着动,也就是在onScroll事件中改变其top值,并刷新当前组件。我们来看看具体代码是怎么实现的:

export class DraggableCalendar extends Component {

  constructor(props) {

    // ...

    this._scrollY = 0;
    this._panResponder = {};

    this._onScroll = this._onScroll.bind(this);
  }

  componentWillMount() {
    this._initPanResponder();
  }

  _initPanResponder() {
    // TODO
  }

  _genDraggableAreaStyle(date) {
    if(!date) {
      return null;
    } else {
      if(Helper.isEmptyObject(this._dayLayouts)) {
        return null;
      } else {
        const {x, y, width, height} = this._dayLayouts[Helper.formatDate(date, 'yyyy-MM-dd')];
        return {left: x, top: y - this._scrollY, width, height};
      }
    }
  }

  _onScroll(e) {
    this._scrollY = Helper.getValue(e, 'nativeEvent:contentOffset:y', this._scrollY);
    clearTimeout(this.updateTimer);
    this.updateTimer = setTimeout(() => {
      this.forceUpdate();
    }, 100);
  }

  _renderBody() {
    const {calendarData} = this.state;
    return (
      <View style={styles.bodyContainer}>
        <ScrollView scrollEventThrottle={1} onScroll={this._onScroll}>
          {Object
            .keys(calendarData)
            .map((key, index) => this._renderMonth({identifier: key, data: calendarData[key], index}))
          }
        </ScrollView>
        {this._renderDraggableArea()}
      </View>
    );
  }

  _renderDraggableArea() {
    const {startDate, endDate} = this.state;
    if(!startDate || !endDate) {
      return null;
    } else {
      const isSingleChosen = startDate.getTime() === endDate.getTime();
      return [
        <View
          key={'drag-start'}
          {...this._panResponder.panHandlers}					style={[styles.dragContainer, this._genDraggableAreaStyle(startDate)]}
        />,
        <View
          key={'drag-end'}
          {...this._panResponder.panHandlers}
          style={[styles.dragContainer, this._genDraggableAreaStyle(endDate), isSingleChosen && {height: 0}]}
        />
      ];
    }
  }

  // ...
}

注意:state中的startDate和endDate是当前选中时间范围的第一天和最后一天。由于现在都还没有值,所以目前看不出效果。

接下来,我们再实现最重要的_initPanResponder方法。PanResponder提供了很多回调,在这里,我们主要用到的就只有5个:

  1. onStartShouldSetPanResponder:开始的时候申请成为响应者;
  2. onMoveShouldSetPanResponder:移动的时候申请成为响应者;
  3. onPanResponderGrant:开始手势操作;
  4. onPanResponderMove:移动中;
  5. onPanResponderRelease:手指放开,手势操作结束。

除此之外,以上的回调函数都会携带两个参数:event和gestureState,它们中包含了非常重要的信息。在这里,我们主要用到的是:

event.nativeEvent:

  1. locationX: 触摸点相对于父元素的横坐标
  2. locationY: 触摸点相对于父元素的纵坐标

gestureState:

  1. dx: 从触摸操作开始时的累计横向路程
  2. dy: 从触摸操作开始时的累计纵向路程

因此,我们可以在onPanResponderGrant记录下一开始手指的坐标,然后在onPanResponderMove中获取deltaX和deltaY,相加之后就得到当前手指的实时坐标。一起来看下代码:

export class DraggableCalendar extends Component {

  constructor(props) {
    // ...

    this.state = {
      startDate: new Date(2018, 5, 7, 0, 0, 0),
      endDate: new Date(2018, 5, 10, 0, 0, 0),
      calendarData: this._genCalendarData({fullDateRange, availableDateRange, maxDays})
    };

    this._touchPoint = {};

    this._onPanGrant = this._onPanGrant.bind(this);
    this._onPanMove = this._onPanMove.bind(this);
    this._onPanRelease = this._onPanRelease.bind(this);
  }

  _initPanResponder() {
    this._panResponder = PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onMoveShouldSetPanResponder: () => true,
      onPanResponderGrant: this._onPanGrant,
      onPanResponderMove: this._onPanMove,
      onPanResponderRelease: this._onPanRelease
    });
  }

  _onPanGrant(evt) {
    // save the initial position
    const {locationX, locationY} = evt.nativeEvent;
    this._touchPoint.x = locationX;
    this._touchPoint.y = locationY;
  }

  _onPanMove(evt, gesture) {

    // save the delta offset
    const {dx, dy} = gesture;
    this._touchPoint.dx = dx;
    this._touchPoint.dy = dy;

    // console for test
    console.log('(x, y):', this._touchPoint.x + dx, this._touchPoint.y + dy);
  }

  _onPanRelease() {
    // clear the saved info
    this._touchPoint = {};
  }

  // ...
}

我们给state中的startDate和endDate随意加个值,并给draggableArea加个半透明的红色来测试下,我们的手势操作到底有没有起作用。

咦~ 怎么console得到的值看起来好像不太对。打印出来的(x, y)像是相对draggableArea的坐标,而不是整个ScrollView的坐标。不过这也好办,因为我们知道draggableArea的left和top值,所以加上就好了。我们可以在onTouchStart这个函数中做这件事,同时还可以区分当前手指触摸的是选中时间范围内的第一天还是最后一天。代码如下:

export class DraggableCalendar extends Component {

  constructor(props) {
    // ...
    this._pressEnd = false;
    this._pressStart = false;
  }

  _onTouchStart(type, date) {
    const pressMap = {start: '_pressStart', end: '_pressEnd'};
    this[pressMap[type]] = true;
    if(this._pressStart || this._pressEnd) {
      const dateStr = Helper.formatDate(date, 'yyyy-MM-dd');
      this._touchPoint.x += Helper.getValue(this, `_dayLayouts:${dateStr}:x`, 0);
      this._touchPoint.y += Helper.getValue(this, `_dayLayouts:${dateStr}:y`, 0);
    }
  }

  _renderDraggableArea() {
    const {startDate, endDate} = this.state;
    if(!startDate || !endDate) {
      return null;
    } else {
      const isSingleChosen = startDate.getTime() === endDate.getTime();
      return [
        <View
          key={'drag-start'}
          {...this._panResponder.panHandlers}
          onTouchStart={() => this._onTouchStart('start', startDate)}
          style={[styles.dragContainer, this._genDraggableAreaStyle(startDate)]}
        />,
        <View
          key={'drag-end'}
          {...this._panResponder.panHandlers}
          onTouchStart={() => this._onTouchStart('end', endDate)}
          style={[styles.dragContainer, this._genDraggableAreaStyle(endDate), isSingleChosen && {height: 0}]}
        />
      ];
    }
  }

  // ...
}

2.3.2 坐标信息转换成日期信息

根据上面的步骤,我们已经成功地获取到了当前手指触摸的实时坐标。所以,接下来就是把该坐标转换成落在哪个日期上,从而可以判断出选中日期是否发生变化。

这一步,说简单也简单,要想复杂那也可以复杂。简单来看。我们的this._dayLayouts保存了所有Day的layout,我们只需要进行遍历,判断手指坐标有没有落在某个Day的范围当中即可。复杂来讲,就是减少不必要的比较次数。不过,我们还是先实现功能为主,优化步骤在后面介绍。实现代码如下:

// Helper.js
export const Helper = {
  // ...
  positionToDate(position, dayLayouts) {
    let date = null;
    Object.keys(dayLayouts).forEach(key => {
      const {x, y} = position, layout = dayLayouts[key];
      if(
        x >= layout.x &&
        x <= layout.x + layout.width &&
        y >= layout.y &&
        y <= layout.y + layout.height
      ) {
        date = Helper.parseDate(key);
      }
    });
    return date;
  }
}

// DraggableCalendar.js
export class DraggableCalendar extends Component {
  // ...
  _onPanMove(evt, gesture) {
    // ...
    // for test
    console.log('cur date:', Helper.positionToDate({x: this._touchPoint.x + dx, y: this._touchPoint.y + dy}, this._dayLayouts));
  }
}

2.3.3 对比前后选中日期,触发渲染

经过上一步的positionToDate,我们知道了当前手指落在哪一天上。接下来,就是比较当前新的选中日期和拖动之前旧的选中日期,看看有没有发生变化。

**特别注意:**假如我们一开始手指是触摸在start上,但是拖动之后手指停留的日期已经大于end上的日期;或者反过来,一开始触摸在end上,拖动之后手指停留的日期小于start上的日期。这种特殊情况下,pressStart和pressEnd其实发生了变化,所以需要特殊处理。我们来看看代码是怎么写的:

// Helper.js
export const Helper = {
  getDayStatus(date, selectionRange = []) {

    let status = DAY_STATUS.NONE;
    const [startDate, endDate] = selectionRange;

    if(!startDate || !endDate) {
      return status;
    }

    if(startDate.getTime() === endDate.getTime()) {
      if(date.getTime() === startDate.getTime()) {
        return DAY_STATUS.SINGLE_CHOSEN;
      }
    } else {
      if(date.getTime() === startDate.getTime()) {
        return DAY_STATUS.RANGE_BEGIN_CHOSEN;
      } else if(date > startDate && date < endDate) {
        return DAY_STATUS.RANGE_MIDDLE_CHOSEN;
      } else if(date.getTime() === endDate.getTime()) {
        return DAY_STATUS.RANGE_END_CHOSEN;
      }
    }

    return status;
  }
};

// DraggableCalendar.js
export class DraggableCalendar extends Component {

  _updateDayStatus(selectionRange) {

    const {calendarData} = this.state;
    Object.keys(calendarData).forEach(key => {

      // set a flag: if status has changed, it means this month should be re-rendered.
      let hasChanged = false;
      calendarData[key].forEach(dayData => {
        if(dayData.date) {
          const newDayStatus = Helper.getDayStatus(dayData.date, selectionRange);
          if(dayData.status !== newDayStatus) {
            hasChanged = true;
            dayData.status = newDayStatus;
          }
        }
      });

      // as monthBody is FlatList, the data should be two objects. Or it won't be re-rendered
      if(hasChanged) {
        calendarData[key] = Object.assign([], calendarData[key]);
      }
    });

    this.setState({calendarData});
  }

  _updateSelection() {

    const {x, dx, y, dy} = this._touchPoint;
    const touchingDate = Helper.positionToDate({x: x + dx, y: y + dy}, this._dayLayouts);

    // if touchingDate doesn't exist, return
    if(!touchingDate) return;

    // generates new selection dateRange
    let newSelection = [], {startDate, endDate} = this.state;
    if(this._pressStart && touchingDate.getTime() !== startDate.getTime()) {
      if(touchingDate <= endDate) {
        newSelection = [touchingDate, endDate];
      } else {
        this._pressStart = false;
        this._pressEnd = true;
        newSelection = [endDate, touchingDate];
      }
    } else if(this._pressEnd && touchingDate.getTime() !== endDate.getTime()) {
      if(touchingDate >= startDate) {
        newSelection = [startDate, touchingDate];
      } else {
        this._pressStart = true;
        this._pressEnd = false;
        newSelection = [touchingDate, startDate];
      }
    }

    // if selection dateRange changes, update it
    if(newSelection.length > 0) {
      this._updateDayStatus(newSelection);
      this.setState({startDate: newSelection[0], endDate: newSelection[1]});
    }
  }

  _onPanMove(evt, gesture) {
    // ...
    this._updateSelection();
  }
}

这里需要对_updateDayStatus函数进行稍加解释:
我们在renderMonthBody用的是FlatList,由于FlatList是纯组件,所以只有当props发生变化时,才会重新渲染。虽然我们在_updateDayStatus中更新了calendarData,但其实是同一个对象。所以,分配给renderMonthBody的data也会是同一个对象。为此,我们在更新Day的status时用一个flag来表示该月份中是否有日期的状态发生变化,如果发生变化,我们会用Object.assign来复制一个新的对象。这样一来,状态发生变化的月份会重新渲染,而没有发生变化的月份不会,这反而算是一个性能上的优化吧。

2.4 其他

其实,上面我们已经实现了基本的拖拽操作。但是,还有一些遗留的小问题:

  1. 用户点选非选中时间段的日期,应该重置当前选中日期;
  2. 用户手指停留的日期是unavailable(即不可操作的)时,该日期不应该被选中;
  3. 组件应支持在初始化的时候选中props中指定的一段时间范围;
  4. 手指在滑动到月初/月末空白区域时,也能响应选中月初/月末;
    ...

当然了,上面的这些问题都是细节问题,考虑篇幅原因,就不再详述了。。。

但是!性能优化问题是肯定要讲的!因为,就目前做出来的这东西在ios上表现还可以,但是在android上拖动的时候,会有一点卡顿感。尤其是在性能差的机子上,卡顿感就更明显了。。。

3. 性能优化

我们都知道,react性能上的优化很大程度上得益于其强大的DomDiff,通过它可以减少dom操作。但是过多的DomDiff也是一个消耗,所以怎么减少无谓的DomDiff呢?答案是正确地使用shouldComponentUpdate函数,不过我们还是得首先找出哪些是无谓的DomDiff。

为此,我们可以在我们写的所有_renderXXX函数中打一个log,在手指拖动的时候,都有哪些组件一直在render?

经过试验,可以发现每次选中日期发生变化的时候,_renderMonth,_renderMonthHeader,_renderMonthBody和_renderDay这几个函数会触发很多次。原因很简单,当选中日期发生变化时,我们通过setState更新了clendarData,从而触发了整个日历重新render。因此,每个month都会重新渲染,相应的这几个render函数都会触发一遍。

3.1 减少renderMonth的DomDiff

既然源头已经找到,我们就可以对症下药了。其实也简单,我们每次只要更新状态发生变化的月份就可以,其他的月份可以省略其DomDiff过程。

但是!!!这个解决方案有一个弊端,就是需要维护changingMonth这个变量。每次手指拖动操作的时候,我们都得计算出哪些月份是发生状态变化的;手指释放之后,又得重置changingMonth。而且,现在这个组件的操作逻辑相对来说还比较简单,如果交互逻辑往后变得越来越复杂,那这个维护成本会继续上升。。。

所以,我们可以换个思路~ month不是每次都会DomDiff吗?没关系,我把month中的子组件封装成PureComponent,这样子组件的DomDiff过程是会被优化掉的。所以,即使每次渲染month,也会大大减少无谓的DomDiff操作。而_renderMonthBody用的是FlatList,这已经是纯组件了,所以已经起到一定的优化效果,不然_renderDay的触发次数会更多。因此,我们要做的只是把_renderMonthHeader改造成纯组件就好了。来看看代码:

// MonthHeader.js
export class MonthHeader extends PureComponent {
  render() {
    const {identifier, monthHeaderTextStyle, renderMonthHeader} = this.props;
    const [year, month] = identifier.split('-');
    return (
      <View>
        {renderMonthHeader ?
          renderMonthHeader(identifier) :
          <Text style={[styles.monthHeaderText, monthHeaderTextStyle]}>
            {`${parseInt(year)}${parseInt(month)}月`}
          </Text>
        }
      </View>
    );
  }
}

// DraggableCalendar.js
export class DraggableCalendar extends Component {
  // ...
  _renderMonthHeader({identifier}) {
    const {monthHeaderTextStyle, renderMonthHeader} = this.props;
    return (
      <MonthHeader
        key={identifier}
        identifier={identifier}
        monthHeaderTextStyle={monthHeaderTextStyle}
        renderMonthHeader={renderMonthHeader}
      />
    );
  }
}

3.2 减少renderDay的DomDiff

根据前面的试验结果,其实我们可以发现每次渲染月份的时候,这个月份中的所有DayItem都会被渲染一遍。但实际上只需要状态发生变化的DayItem重新渲染即可。所以,这又给了我们优化的空间,可以进一步减少无谓的DomDiff。

上面的例子已经证明PureComponent是再好不过的优化利器了~ 所以,我们继续把_renderDay改造成纯组件,来看代码:

// Day.js
export class Day extends PureComponent {

  _genStyle() {
    const {
      data, dayTextStyle, selectedDayTextStyle,
      dayContainerStyle, singleDayContainerStyle,
      beginDayContainerStyle, middleDayContainerStyle, endDayContainerStyle
    } = this.props;
    const usedDayTextStyle = [styles.dayText, dayTextStyle];
    const usedDayContainerStyle = [styles.dayContainer, dayContainerStyle];
    if(data.status !== DAY_STATUS.NONE) {
      const containerStyleMap = {
        1: [styles.singleDayContainer, singleDayContainerStyle],
        2: [styles.beginDayContainer, beginDayContainerStyle],
        3: [styles.middleDayContainer, middleDayContainerStyle],
        4: [styles.endDayContainer, endDayContainerStyle]
      };
      usedDayTextStyle.push(styles.selectedDayText, selectedDayTextStyle);
      usedDayContainerStyle.push(...(containerStyleMap[data.status] || {}));
    }
    return {usedDayTextStyle, usedDayContainerStyle};
  }

  render() {
    const {data, renderDay} = this.props;
    const {usedDayTextStyle, usedDayContainerStyle} = this._genStyle();
    return (
      <View style={{flex: 1}}>
        {renderDay ?
          renderDay(data) :
          <View style={usedDayContainerStyle}>
            {data.date && (
              <Text style={[...usedDayTextStyle, !data.available && {opacity: .6}]}>
                {data.date.getDate()}
              </Text>
            )}
          </View>
        }
      </View>
    );
  }
}

// DraggableCalendar.js
export class DraggableCalendar extends Component {
  // ...
  _renderDay(item, index) {
    const styleKeys = [
      'dayTextStyle', 'selectedDayTextStyle',
      'dayContainerStyle', 'singleDayContainerStyle',
      'beginDayContainerStyle', 'middleDayContainerStyle', 'endDayContainerStyle'
    ];
    return (
      <Day
        key={`day-${index}`}
        data={item}
        status={item.status}
        {...styleKeys.map(key => this.props[key])}
      />
    );
  }
}

3.3 减少positionToDate的查找次数

经过上面两步,已经减缓了一部分的DomDiff开销了。那还有什么可以优化的呢?还记得前文提到的positionToDate函数么?目前我们是通过遍历的方式将坐标转换成日期的,时间复杂度是O(n),所以这里还有优化的空间。那么又该怎么优化呢?

这时以前学的算法是终于有用武之地了,哈哈~ 由于日历中的日期排版很有规律,从左到右看,都是递增的;从上到下看,也是递增的。so~ 我们可以用二分查找来减少这个查找次数,将时间复杂度降到O(nlog2)。不过,在这个case中,我们应当如何使用二分呢?

其实,我们可以使用3次二分:

  1. 因为Month垂直方向上是递增的,纵坐标y也是递增的,所以先用二分定位到当前手指落在哪个月份中;
  2. 同一个月内,水平方向上横坐标x是递增的,所以再用一次二分定位到当前手指落在周几上;
  3. 同一个月内,垂直方向上纵坐标y是递增的,可以再用一次二分定位到当前手指落在哪天上。

思路已经有了,可是我们的this._dayLayouts是一个对象,没法操作。所以,我们需要做一层转换,姑且就叫索引吧,这样显得洋气~~~ 来看代码:

// Helper.js
export const Helper = {
  // ...
  arrayTransform(arr = []) {

    if(arr.length === 0) return [];

    let result = [[]], lastY = arr[0].y;
    for(let i = 0, count = 0; i < arr.length; i++) {
      if(arr[i].y === lastY) {
        result[count].push(arr[i]);
      } else {
        lastY = arr[i].y;
        result[++count] = [arr[i]];
      }
    }

    return result;
  },
  buildIndexItem({identifier, dayLayouts, left, right}) {
    const len = dayLayouts.length;
    return {
      identifier,
      boundary: {
        left, right, upper: dayLayouts[0].y,
        lower: dayLayouts[len - 1].y + dayLayouts[len - 1].height
      },
      dayLayouts: Helper.arrayTransform(dayLayouts.map((item, index) => {
        const date = `${identifier}-${index + 1}`;
        if(index === 0){
          return Object.assign({date}, item, {x: left, width: item.x + item.width - left});
        } else if (index === len - 1) {
          return Object.assign({date}, item, {width: right - item.x});
        } else {
          return Object.assign({date}, item);
        }
      }))
    };
  }
};

// DraggableCalendar.js
export class DraggableCalendar extends Component {

  constructor(props) {
    // ...
    this._dayLayoutsIndex = [];
  }

  _genDayLayout(identifier, layout) {
    // ...
    // build the index for days' layouts to speed up transforming (x, y) to date
    this._dayLayoutsIndex.push(Helper.buildIndexItem({
      identifier, left: x, right: x + width,
      dayLayouts: Object.keys(dayLayouts).map(key => dayLayouts[key])
    }));
  }

  // ...
}

从上面打印出来的索引结果中,我们可以看到建立索引的过程主要是干了两件事:

  1. 保存下了每个月的上下左右边界,这样就可以用二分快速找到当前手指落在哪个月份中了;
  2. 将原本一维的dayLayouts转换成了二维数组,与日历的展示方式保持一致,目的也是为了方便二分查找。

接下来再看看二分查找的代码:

// Helper.js
export const Helper = {
  binarySearch(data=[], comparedObj, comparedFunc) {

    let start = 0;
    let end = data.length - 1;
    let middle;

    let compareResult;
    while(start <= end) {
      middle = Math.floor((start + end) / 2);
      compareResult = comparedFunc(data[middle], comparedObj);
      if(compareResult < 0) {
        end = middle - 1;
      } else if(compareResult === 0) {
        return data[middle];
      } else {
        start = middle + 1;
      }
    }

    return undefined;
  },
  positionToDate(position, dayLayoutsIndex) {

    // 1. use binary search to find the monthIndex
    const monthData = Helper.binarySearch(dayLayoutsIndex, position, (cur, compared) => {
      if(compared.y < cur.boundary.upper) {
        return -1;
      } else if(compared.y > cur.boundary.lower) {
        return 1;
      } else {
        return 0;
      }
    });

    // 2. use binary search to find the rowData
    if(monthData === undefined) return null;
    const rowData = Helper.binarySearch(monthData.dayLayouts, position, (cur, compared) => {
      if(compared.y < cur[0].y) {
        return -1;
      } else if(compared.y > cur[0].y + cur[0].height) {
        return 1;
      } else {
        return 0;
      }
    });

    // 3. use binary search to find the result
    if(rowData === undefined) return null;
    const result = Helper.binarySearch(rowData, position, (cur, compared) => {
      if(compared.x < cur.x) {
        return -1;
      } else if(compared.x > cur.x + cur.width) {
        return 1;
      } else {
        return 0;
      }
    });

    // 4. return the final result
    return result !== undefined ? Helper.parseDate(result.date) : null;
  }
  // ...
};

我们来绝歌例子看看优化的效果:假如渲染的日历数据有6个月的内容,也就是180天。最坏的情况下,原先需要查找180次才有结果。而现在呢?月份最多3次能确定,row最多3次能确定,col最多3次能确定,也就是最多9次就能找到结果。

啊哈~ 简直是文美~ 再看看手指拖拽时的效果,丝毫没有卡顿感,妈妈再也不用担心RN在android上的性能效果啦~

4. 实战

费了那么大劲儿,又是封装组件,又是优化性能的,现在终于可以能派上用场啦~ 为了应对产品变化多端的需求,我们早就对日历的样式做了可配置化。

来看看效果咋样:

5. 写在最后

看着眼前的这个demo,也算是收获不小,既接触了RN的手势系统,还涨了一波组件的优化经验,甚至还用到了二分查找~ 嘿嘿嘿,美滋滋~

老规矩,本文代码地址:

https://github.com/SmallStoneSK/react-native-draggable-calendar

Flutter滚动型容器组件 - ListView篇

cover

1. 前言

Flutter作为时下最流行的技术之一,凭借其出色的性能以及抹平多端的差异优势,早已引起大批技术爱好者的关注,甚至一些闲鱼美团腾讯等大公司均已投入生产使用。虽然目前其生态还没有完全成熟,但身靠背后的Google加持,其发展速度已经足够惊人,可以预见将来对Flutter开发人员的需求也会随之增长。

无论是为了技术尝鲜还是以后可能的工作机会,都9102年了,作为一个前端开发者,似乎没有理由不去尝试它。正是带着这样的心理,笔者也开始学习Flutter,同时建了一个用于练习的仓库,后续所有代码都会托管在上面,欢迎star,一起学习。

在上一篇文章中,我们学习了Flutter中使用频率最高的一些基础组件。但是在一些场景中,当组件的宽度或高度超出屏幕边缘时,Flutter往往会给出overflow警告,提醒有组件溢出屏幕。为了解决这个问题,今天我们就来学习最常用的一个滚动型容器组件ListView组件

2. ListView使用方法

从功能比较上来看,Flutter中的ListView组件和RN中的ScrollView/FlatList非常相似,但是在使用方法上还是有点区别。接下来,就跟着我一起来看看ListView组件都有哪些常用的使用方法。

2.1 ListView()

第一种使用方法就是直接调用其默认构造函数来创建列表,效果等同于RN中的ScrollView组件。但是这种方式创建的列表存在一个问题:对于那些长列表或者需要较昂贵渲染开销的子组件,即使还没有出现在屏幕中但仍然会被ListView所创建,这将是一项较大的开销,使用不当可能引起性能问题甚至卡顿

不过话说回来,虽然该方法可能会有性能问题,但还是取决于其不同的应用场景而定。下面我们就来看下其构造函数(已省略不常用属性):

ListView({
  Axis scrollDirection = Axis.vertical,
  ScrollController controller,
  ScrollPhysics physics,
  bool shrinkWrap = false,
  EdgeInsetsGeometry padding,
  this.itemExtent,
  double cacheExtent,
  List<Widget> children = const <Widget>[],
})
  • scrollDirection: 列表的滚动方向,可选值有Axishorizontalvertical,可以看到默认是垂直方向上滚动;
  • controller : 控制器,与列表滚动相关,比如监听列表的滚动事件;
  • physics: 列表滚动至边缘后继续拖动的物理效果,AndroidiOS效果不同。Android会呈现出一个波纹状(对应ClampingScrollPhysics),而iOS上有一个回弹的弹性效果(对应BouncingScrollPhysics)。如果你想不同的平台上呈现各自的效果可以使用AlwaysScrollableScrollPhysics,它会根据不同平台自动选用各自的物理效果。如果你想禁用在边缘的拖动效果,那可以使用NeverScrollableScrollPhysics
  • shrinkWrap: 该属性将决定列表的长度是否仅包裹其内容的长度。当ListView嵌在一个无限长的容器组件中时,shrinkWrap必须为true,否则Flutter会给出警告;
  • padding: 列表内边距;
  • itemExtent: 子元素长度。当列表中的每一项长度是固定的情况下可以指定该值,有助于提高列表的性能(因为它可以帮助ListView在未实际渲染子元素之前就计算出每一项元素的位置);
  • cacheExtent: 预渲染区域长度,ListView会在其可视区域的两边留一个cacheExtent长度的区域作为预渲染区域(对于ListView.buildListView.separated构造函数创建的列表,不在可视区域和预渲染区域内的子元素不会被创建或会被销毁);
  • children: 容纳子元素的组件数组。

上面的属性介绍一大堆,都不如一个实际例子来得实在。我们可以用一个ListView组件来包裹上篇文章中实现的银行卡宠物卡片朋友圈这三个例子:

代码(文件地址

class NormalList extends StatelessWidget {

  const NormalList({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: <Widget>[
        CreditCard(data: creditCardData),
        PetCard(data: petCardData),
        FriendCircle(data: friendCircleData),
      ],
    );
  }
}

预览

可以看到,默认构造函数的用法非常之简单,直接把子元素组件放在children数组中就可以了。但是潜在的问题前面也已经解释过,对于长列表这种应用场景还是应该用ListView.build构造函数性能会更好。

2.2 ListView.build()

ListView默认构造函数虽使用简单,但不适用于长列表。为此,我们来看下ListView.build构造函数:

ListView.builder({
  ...
  int itemCount,
  @required IndexedWidgetBuilder itemBuilder,
})

这里省略了不常用以及和ListView默认构造函数重复的一些参数,相比之下我们可以发现ListView.builder多了两个新的参数:

  • itemCount: 列表中元素的数量;
  • itemBuilder: 子元素的渲染方法,允许自定义子元素组件(等同于rnFlatList组件的renderItem属性)。

不同于ListView默认构造函数通过children参数指定子元素的这种方式,ListView.build通过暴露统一的itemBuilder方法将渲染子元素的控制权交还给调用方。这里我们用一个微信公众号的例子来说明ListView.build的使用方法(公众号卡片的样式布局可以看这里,也算是对基础组件的一个巩固和复习):

代码(文件地址

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

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Color(0xFFEFEFEF),
      child: ListView.builder(
        itemCount: subscribeAccountList.length,
        itemBuilder: (context, index) {
          return SubscribeAccountCard(data: subscribeAccountList[index]);
        },
      ),
    );
  }
}

预览

根据上面的代码可以看到,ListView.build创建列表最重要的两个参数就是itemCountitemBuilder。对于公众号列表这个例子,由于每个公众号消息卡片的布局都是有规则的,而且这个列表的数量可能非常之多,所以用ListView.build来创建再适合不过了。

2.3 ListView.separated()

绝大多数列表类的需求我们都可以用ListView.build构造函数来解决问题,不过有的列表子项之间需要分割线,此时我们可以用Flutter提供的另一个构造函数ListView.separated来创建列表。来看下其构造函数有什么不同:

ListView.separated({
  ...
  @required IndexedWidgetBuilder separatorBuilder
})

相比于ListView.build 构造函数,可以看到ListView.separated仅仅是多了一个separatorBuilder必填参数。顾名思义,这就是暴露给调用方自定义分割线组件的回调方法。以支付宝的好友列表为例(好友卡片的样式布局可以看这里),我们来看下ListView.separated的使用方法:

代码(文件地址

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

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      itemCount: friendListData.length,
      itemBuilder: (context, index) {
        return FriendCard(data: friendListData[index]);
      },
      separatorBuilder: (context, index) {
        return Divider(
          height: .5,
          indent: 75,
          color: Color(0xFFDDDDDD),
        );
      },
    );
  }
}

预览

看代码可以知道不同点就在于实现了separatorBuilder这个函数,通过它我们可以自定义每个子元素之间的分割线了。

2.4 小结

到目前为止,我们一共学习了ListViewListView.buildListView.separated三种创建列表的方式,它们各自都有其适用的场景,所以遇到需求时还是得具体问题具体分析。

不过,其实ListView还有一个构造函数:ListView.custom。而且ListView.buildListView.separated最终都是通过ListView.custom实现的。但是本文并不打算介绍这种方法,因为一般情况下前面提到的三种构造方法就已经足够解决问题了(以后遇到实际问题再研究这个)。

3. ListView进阶方法

上文我们介绍了ListView的基础用法,但是在实际的产品中,我们还会遇到列表下拉刷新上拉加载等需求。接下来,就让我们学习下Flutter中应该如何实现此类交互操作。

3.1 下拉刷新

要在Flutter中实现列表的下拉刷新效果,其实非常简单,因为Flutter给我们封装好了一个RefreshIndicator组件,使用起来也非常方便。看下示例代码:

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

  @override
  _PullDownRefreshListState createState() => _PullDownRefreshListState();
}

class _PullDownRefreshListState extends State<PullDownRefreshList> {

  Future onRefresh() {
    return Future.delayed(Duration(seconds: 1), () {
      Toast.show('当前已是最新数据', context);
    });
  }

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      onRefresh: this.onRefresh,
      child: ListView.separated(
        itemCount: friendListData.length,
        itemBuilder: (context, index) {
          return FriendCard(data: friendListData[index]);
        },
        separatorBuilder: (context, index) {
          return Divider(
            height: .5,
            indent: 75,
            color: Color(0xFFDDDDDD),
          );
        },
      ),
    );
  }
}

由于列表的数据源是可变的,因此这次的组件我们选用继承自StatefulWidget

可以看到RefreshIndicator的用法十分简单,只要将我们原来的ListView作为其child,并且实现其onRefresh方法就好了。而onRefresh方法其实是刷新完毕通知RefreshIndicator的一个回调函数。上述代码中,我们模拟了一个1s的等待当做网络请求,然后弹出一个Toast提示"已经是最新数据"(此处的Toast是安装了toast: ^0.1.3这个包,Flutter原生并没有提供)。

这里模仿了今日头条的列表UI作为示例(新闻卡片的样式布局可以看这里),我们来看下效果:

可以看到一切都如预期成功执行了,效果还是不错的,而且RefreshIndicator使用起来也是非常简便。但是,由于Flutter封装好的RefreshIndicator组件可定制性有点弱,不太能够满足大多数app中自定义样式的要求。不过好在看了下RefreshIndicator的源码并不是很多,等日后学了动画再回头来研究下如何定制一个自定义的下拉刷新组件。

3.2 上拉加载

除了下拉刷新之外,上拉加载是经常会遇到的另一种列表操作。不过,这次Flutter倒是没有像下拉刷新那样提供现成的组件可以直接调用,上拉加载的交互需要我们自己完成。为此,我们先来简单分析下:

  1. 组件内部需要一个list变量存储当前列表的数据源;
  2. 组件内部需要一个bool型的isLoading标志位来表示当前是否处于Loading状态;
  3. 需要能够判断出当前列表是否已经滚动到底部,而这就要借助到我们前面提到过的controller属性了(ScrollController可以获取到当前列表的滚动位置以及列表最大滚动区域,相比较即可得到结果);
  4. 当开始加载数据的时候,需要将isLoading置为true;当数据加载完毕的时候,需要将新的数据合并到list变量中,并且重新将isLoading置为false

根据上面的思路,我们可以得到下面的代码:

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

  @override
  _PullUpLoadMoreListState createState() => _PullUpLoadMoreListState();
}

class _PullUpLoadMoreListState extends State<PullUpLoadMoreList> {
  bool isLoading = false;
  ScrollController scrollController = ScrollController();
  List<NewsViewModel> list = List.from(newsList);

  @override
  void initState() {
    super.initState();
    // 给列表滚动添加监听
    this.scrollController.addListener(() {
      // 滑动到底部的关键判断
      if (
        !this.isLoading &&
        this.scrollController.position.pixels >= this.scrollController.position.maxScrollExtent
      ) {
        // 开始加载数据
        setState(() {
          this.isLoading = true;
          this.loadMoreData();
        });
      }
    });
  }

  @override
  void dispose() {
    // 组件销毁时,释放资源(一定不能忘,否则可能会引起内存泄露)
    super.dispose();
    this.scrollController.dispose();
  }

  Future loadMoreData() {
    return Future.delayed(Duration(seconds: 1), () {
      setState(() {
        this.isLoading = false;
        this.list.addAll(newsList);
      });
    });
  }

  Widget renderBottom() {
    // TODO
  }

  @override
  Widget build(BuildContext context) {
    return ListView.separated(
      controller: this.scrollController,
      itemCount: this.list.length + 1,
      separatorBuilder: (context, index) {
        return Divider(height: .5, color: Color(0xFFDDDDDD));
      },
      itemBuilder: (context, index) {
        if (index < this.list.length) {
          return NewsCard(data: this.list[index]);
        } else {
          return this.renderBottom();
        }
      },
    );
  }
}

其中有一点需要注意,列表的itemCount值变成了list.length + 1,这是因为我们多渲染了一个底部组件。当不在加载的时候,我们可以展示一个上拉加载更多的提示性组件;当正在加载数据时,我们又可以展示一个努力加载中...的占位组件。renderBottom的实现如下:

Widget renderBottom() {
  if(this.isLoading) {
    return Container(
      padding: EdgeInsets.symmetric(vertical: 15),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Text(
            '努力加载中...',
            style: TextStyle(
              fontSize: 15,
              color: Color(0xFF333333),
            ),
          ),
          Padding(padding: EdgeInsets.only(left: 10)),
          SizedBox(
            width: 20,
            height: 20,
            child: CircularProgressIndicator(strokeWidth: 3),
          ),
        ],
      ),
    );
  } else {
    return Container(
      padding: EdgeInsets.symmetric(vertical: 15),
      alignment: Alignment.center,
      child: Text(
        '上拉加载更多',
        style: TextStyle(
          fontSize: 15,
          color: Color(0xFF333333),
        ),
      ),
    );
  }
}

最后,我们再来看下最终的实现效果:

4. 总结

首先,本文介绍了常用的ListViewListView.buildListView.separated三种构造方法来创建列表,并结合实际的例子加以说明其不同的使用场景。紧接着,又介绍了列表组件下拉刷新上拉加载这两个较常用到的交互操作在Flutter中应该如何实现。

通过文中的5个实际例子,相信你一定已经对Flutter中如何使用ListView有了初步了解,剩下的就是多练习(盘它)咯~

本文所有代码托管在这儿,欢迎一起交流学习~

高阶组件HOC - 小试牛刀

高阶组件HOC - 小试牛刀

1. 前言

老毕曾经有过一句名言,叫作“国庆七天乐,Coding最快乐~”。所以在这漫漫七天长假,手痒了怎么办?于是乎,就有了接下来的内容。。。

2. 一个中心

今天要分享的内容有关高阶组件的使用。

虽然这类文章早已经烂大街了,而且想必各位看官也是稔熟于心。因此,本文不会着重介绍一堆HOC的概念,而是通过两个实实在在的实际例子来说明HOC的用法和强大之处。

3. 两个例子

3.1 例子1:呼吸动画

首先,我们来看第一个例子。喏,就是这个。

是滴,这个就是呼吸动画(录的动画有点渣,请别在意。。。),想必大家在绝大多数的APP中都见过这种动画,只不过我这画的非常简陋。在数据ready之前,这种一闪一闪的呼吸动画可以有效地缓解用户的等待心理。

这时,有人就要跳出来说了:“这还不简单,创建个控制opacity的animation,再添加class不就好了。。。”是的,在web的世界中,css animation有时真的可以为所欲为。但是我想说,在RN的世界里,只有Animated才真的好使。

不过话说回来,要用Animated来做这个呼吸动画,的确也很简单。代码如下:

class BreathLoading extends React.PureComponent {

  componentWillMount() {
    this._initAnimation();
    this._playAnimation();
  }

  componentWillUnmount() {
    this._stopAnimation();
  }

  _initAnimation() {
    this.oritention = true;
    this.isAnimating = true;
    this.opacity = new Animated.Value(1);
  }

  _playAnimation() {
    Animated.timing(this.opacity, {
      isInteraction: false,
      duration: params.duration,
      toValue: this.oritention ? 0.2 : 1,
      easing: this.oritention ? Easing.in : Easing.easeOut
    }).start(() => {
      this.oritention = !this.oritention;
      this.isAnimating && this._playAnimation();
    });
  }

  _stopAnimation = () => this.isAnimating = false;

  render = () => <Animated.View style={{opacity: this.opacity, width: 100, height: 50, backgroundColor: '#EFEFEF'}}/>;

}

是的,仅二十几行代码我们就完成了一个简单地呼吸动画。但是问题来了,假如在你的业务需求中有5个、10个场景都需要用到这种呼吸动画怎么办?总不能复制5次、10次,然后修改它们的render方法吧?这也太蠢了。。。

有人会想到:“那就封装一个组件呗。反正呼吸动画的逻辑都是不变的,唯一在变的是渲染部分。可以通过props接收一个renderContent方法,将渲染的实际控制权交给调用方。”那就来看看代码吧:

class BreathLoading extends React.PureComponent {
  // ...省略
  render() {
    const {renderContent = () => {}} = this.props;
    return renderContent(this.opacity);
  }
}

相比较于一开始的例子,现在这个BreathLoading组件可以被复用,调用方只要关注自己渲染部分的内容就可以了。但是说实话,个人在这个组件使用方式上总感觉有点不舒服,有一个不痛不痒的小问题。习惯上来说,在真正使用BreathLoading的时候,我们通常会写出左下图中的这种代码。由于renderContent接收的是一个匿名函数,因此当组件A render的时候,虽然BreathLoading是一个纯组件,但是前后两次接收的renderContent是两个不同的函数,还是会发起一次不必要的domDiff。那还不简单,只要把renderContent中的内容单独抽成一个函数再传进去不就好了(见右下图)。

对溜,这个就是我刚才说的不爽的地方。好端端的一个Loading组件,封装你也封装了,凭啥我还要分两步才能使用。其实BB了那么久,你也知道埋了那么多的铺垫,是时候HOC出场了。。。说来惭愧,在接触HOC之前鄙人一直用的就是上面这种方法来封装。。。直到用上了HOC之后,才发现真香真香。。。

在这里,我们要用到的是高阶组件的代理模式。大家都知道,高阶组件是一个接收参数、返回组件的函数而已。对于这个呼吸动画的例子而言,我们来分析一下:

  1. 接收什么?当然是接收刚才renderContent返回的那个组件啦。
  2. 返回什么?当然是返回我们的BreathLoading组件啦。

OK,看完上面的两句废话之后,再来看下面的代码。

export const WithLoading = (params = {duration: 600}) => WrappedComponent => class extends React.PureComponent {

  componentWillMount() {
    this._initAnimation();
    this._playAnimation();
  }

  componentWillUnmount() {
    this._stopAnimation();
  }

  _initAnimation() {
    this.oritention = true;
    this.isAnimating = true;
    this.opacity = new Animated.Value(1);
  }

  _playAnimation() {
    Animated.timing(this.opacity, {
      isInteraction: false,
      duration: params.duration,
      toValue: this.oritention ? 0.2 : 1,
      easing: this.oritention ? Easing.in : Easing.easeOut
    }).start(() => {
      this.oritention = !this.oritention;
      this.isAnimating && this._playAnimation();
    });
  }

  _stopAnimation = () => this.isAnimating = false;

  render = () => <WrappedComponent opacity={this.opacity} {...this.props}/>;
};

看完上面的代码之后,再回头瞅瞅前面的那两句话,是不是豁然开朗。仔细观察WrappedComponent,我们发现opacity竟然以props的形式传给了它。只要WrappedComponent拿到了关键的opacity,那岂不是想干什么就干什么来着,而且还没有前面说的什么匿名函数和domDiff消耗问题。再配上decorator装饰器,岂不是美滋滋?代码如下:

@WithLoading()
class Test extends React.PureComponent {
  render() {
    const {opacity} = this.props;
    return (
      <View style={{marginTop: 40, paddingHorizontal: 20}}>
        <View style={{marginTop: 20, flexDirection: 'row', justifyContent: 'space-between'}}>
          <Animated.View style={{opacity, backgroundColor: '#EFEFEF', width: 150, height: 20}}/>
          <Animated.View style={{opacity, backgroundColor: '#EFEFEF', width: 150, height: 20}}/>
        </View>
        <View style={{marginTop: 20, flexDirection: 'row', justifyContent: 'space-between'}}>
          <Animated.View style={{opacity, backgroundColor: '#EFEFEF', width: 150, height: 20}}/>
          <Animated.View style={{opacity, backgroundColor: '#EFEFEF', width: 150, height: 20}}/>
        </View>
      </View>
    )
  }
}

相比之下,显然高阶组件的用法更胜一筹。以后不管要做成什么样的呼吸动画,只要加一个@withLoading就搞定了。因为这个高阶函数,赋予了普通组件一种呼吸闪烁的能力(记住这句话,圈起来重点考)。

3.2 例子2:多版本控制的组件

经过上面的例子,我们初步感受到了高阶组件的黑魔法。因为通过它,我们能让一个组件拥有某种能力,能够化腐朽为神奇。。。哦,吹过头了。。。那我们来看第二个例子,也是业务需求中会遇到的场景。为啥?因为善变的产品经常要改版,要做AB!!!

所谓多版本控制的组件,其实就是一个拥有相同功能的组件,由于产品的需求,经历了A版 -> B版 -> C版 -> D版。。。这无穷无尽的改版,有的换个皮肤,改个样式,有的甚至改了交互。

或许对于一个简单的小组件而言,每次改版只要重新创建一个新的组件就可以了。但是,如果对于一个页面级别的Page组件呢?就像下面的这个组件一样,作为容器组件,这个组件充斥着大量复杂的处理逻辑(这里写的是超级简化版的。。。实际应用场景中会复杂的多)。

class X extends Page {

  state = {
    list: []
  };

  componentDidMount() {
    this._fetchData();
  }

  _fetchData = () => setTimeout(() => this.setState({list: [1,2,3]}), 2000);

  onClickHeader = () => console.log('click header');
  
  onClickBody = () => console.log('click body');
  
  onClickFooter = () => console.log('click footer');

  _renderHeader = () => <Header onClick={this.onClickHeader}/>;

  _renderBody = () => <Body data={this.state.list} onClick={this.onClickBody}/>;

  _renderFooter = () => <Footer onClick={this.onClickFooter}/>;

  render = () => (
    <View>
      {this._renderHeader()}
      {this._renderBody()}
      {this._renderFooter()}
    </View>
  );
}

在这种情况下,假如产品要对这个页面做AB该怎么办呢?为了方便做AB,我们当然希望创建一个新的Page组件,然后在源头上根据AB实验分别跳转到PageA和PageB即可。但是如果真的copy一份PageA作为PageB,再修改其render方法的话,那请你好好保重。。。要不然怎么办嘞?另一种很容易想到的办法是在原来Page的render方法中做AB,如下代码:

class X extends Page {

  // ...省略

  _renderHeaderA = () => <HeaderA onClick={this.onClickHeader}/>;

  _renderBodyA = () => <BodyA data={this.state.list} onClick={this.onClickBody}/>;

  _renderFooterA = () => <FooterA onClick={this.onClickFooter}/>;

  _renderHeaderB = () => <HeaderB onClick={this.onClickHeader}/>;

  _renderBodyB = () => <BodyB data={this.state.list} onClick={this.onClickBody}/>;

  _renderFooterB = () => <FooterB onClick={this.onClickFooter}/>;

  render = () => {
    const {version} = this.props;
    return version === 1 ? (
      <View>
        {this._renderHeaderA()}
        {this._renderBodyA()}
        {this._renderFooterA()}
      </View>
    ) : (
      <View>
        {this._renderHeaderB()}
        {this._renderBodyB()}
        {this._renderFooterB()}
      </View>
    );
  }
}

可是这种处理方式有一个很大的弊端!作为Page组件,往往代码量都会比较大,要是再写一堆的renderXXX方法那这个文件势必更加臃肿了。。。要是再改版C、D怎么办?而且非常容易写出诸如version === 1 ? this._renderA() : this._renderB()之类的代码,甚至还有各版本耦合在一起的代码,到了后期就更加没法维护了。

那你到底想怎样。。。为了解决上面臃肿的问题,或许我们可以尝试把这些render方法给移到另外的文件中(这里需要注意两点:由于this问题,我们需要将Page的实例作为ctx传递下去;为了保证组件能够正常render,需要把state展开传递下去),看下代码:

说实话,这段代码写的足够恶心。。。好好的一个组件被拆得支离破碎,用到this的地方全部被替换成了ctx,还将整个state展开传递下去,看着就很隔应,而且很不习惯,对于新接手的人来说也容易造成误解。所以这种hack的方式还是不行,那么到底应该怎么办呢?

噔噔噔噔,高阶组件又要出场了~ 在改造这个Page之前,我们先来想下,现在这个例子和刚才的呼吸动画那个例子有没有什么相似的地方?答案就是:许多逻辑部分都相同,不同点在于渲染部分。所以,我们的重点在于控制render部分,同时还要解决this的指向问题。来看下代码:

重点在两处:一处是constructor的最后一句,我们将renderEntity中方法都绑定到了Page的实例上;另一处则是render方法,我们通过call的方式巧妙地修改了this的指向问题。这样一来,对于PageA和PageB而言,就完全用不到ctx了。我们再来对比下原来的Page组件,利用高阶组件,我们完全就是将相关的render方法挪了一个位置而已,无形之中还保证了本次修改不会影响到原来的功能。

到了这儿,问题似乎都迎刃而解,但其实还有一个瑕疵。。。啥?到底有完没完。。。不信,这时候你给PageB中的子组件再加一个onPressXXX事件试试。是哦,这时候事件该加在哪儿呢。。。很简单,有了renderEntity这个先例,再来一个eventEntity不就好了吗。。。看下代码:

真的是不加不知道,一加吓一跳。。。有了eventEntity之后,思路瞬间豁然开朗。因为通过eventEntity,我们可以将PageA,PageB的事件各自管理,逻辑也被解耦了。我们可以将各版本Page通用的事件仍然保留在Page中,但是各页面独有的事件写在各自的eventEntity中维护。要是日后再想添加新版本的PageC、PageD,或是废弃PageA,维护管理起来都非常方便。

按照剧情,逼也装够了,其实到这里应该要结束了,可是谁让我又知道了高阶组件的反向继承模式呢。。。前一种的方法唯一的缺点就在于为了hack,我们无形中将PageA和PageB拆的支离破碎,各种方法散落在Object的各个角落。而反向继承的巧妙之处就在于高阶函数返回的可以是一个继承自传进来的组件的组件,因此对于之前的代码,我们只要稍加改动即可。看下代码:

相比前一种方法,现在的PageA、PageB显得更加组件了。所以啊,这绕来绕去的,到头来却感觉就只迈出了一小步。。。还记得刚才说要圈起来重点考的那句话吗?对于这个多版本组件的例子,我们只不过是利用高阶组件的形式赋予了PageA,B,C,D这类组件处理该页面业务逻辑的能力。

4. 三点思考

4.1 高阶组件有啥好处?

想必通过上面的两个实际例子,各位看官多多少少已经够体会到高阶组件的好处,因为它确实能够帮助解决平时业务开发中的痛点。其实,高阶组件就是把一些通用的处理逻辑封装在一个高阶函数中,然后返回一个拥有这些逻辑的组件给你。这样一来,你就赋予了一个普通组件某种能力,同时对该组件的入侵也较小。所以啊,如果你的代码中充斥着大量重复性的工作,还不赶紧用起来?

4.2 啥时候用高阶组件?

虽然是建议用高阶组件来解决问题,但可千万别啥都往高阶组件上套。。。实话实说,我还真见过这样的代码。。。但是其实呢,高阶组件本身也只是封装组件的一种方式而已。就比方说文中Loading组件的那个例子,不用高阶不照样能封装一个组件来简化重复性工作吗?

那究竟什么时候用高阶比较合适呢?还记得先前强调了两遍的那句话么?“高阶组件可以赋予一类组件某种能力” 注意这里的关键词【一类】,在你准备使用高阶组件之前想一想,你接下来要做的事情是不是赋予一类组件某种能力?不妨回想一下上面的两个例子,第一个例子是赋予了一类普通组件能够呼吸动画的能力,第二个例子是赋予一类Page组件能够处理当前页面业务逻辑的能力。除此之外,还有一个例子也是特别合适,那就是Animated.createAnimatedComponent,它也是赋予了一类普通组件能够响应Animated.Value变化的能力。所以啊,某种程度上你可以把高阶组件理解为是一种黑魔法,一旦加上了它,你的组件就能拥有某种能力。这个时候,使用高阶组件来封装你的代码再合适不过了。

另外,高阶组件还有一项非常厉害的优势,那就是可以组合。当然了,本文的例子并没有体现出这种能力。但是试想,假如你手上有许多个黑魔法(即高阶组件),当你把它们自由组合在一起加到某个组件上时,是不是可以创造出无限的可能?而相反,如果你在封装一个组件的时候集成了全部这些功能,这个组件势必会非常臃肿,而当另外的组件需要其中某几个类似的功能时,代码还不能复用。。。

4.3 该怎么使用高阶组件?

高阶组件其实共分为两种模式:属性代理 和 反向继承。分别对应上文中的第一个、第二个例子。那该怎么区分使用呢?嘿嘿,自己用用就知道了。看的再多,不如自己动手写一个来的理解更深。本文不是高阶组件的使用教程,只是两个用高阶组件解决实际问题的例子而已。要真想进一步深入了解高阶组件,可以看介绍高阶组件的文章,然后动手实践慢慢体会~ 等到你回过头来再想一下的时候,必定会有一种豁然开朗的感觉。

5. 写在最后

都说高阶组件大法好,以前都嗤之以鼻,直到抱着试一试的心态才发现。。。

真香真香。。。

chrome插件开发 - tab选项卡管理器

1. 前言

继上周第一次开发Chrome插件github-star-trend之后,我就一直寻思有什么现实问题可以用插件来解决呢?正当我在浏览器中搜索寻找灵感时,打开的众多tab选项卡令我灵光一闪。

咦,为什么不做一个插件用来管理tab呢?每次同时打开过多的tab选项卡时,被挤压的标题总是让我分不清哪个是哪个,查看起来十分不便。于是乎,经过一个周末下午的折腾,我倒腾出这么个东西(gif图可能有点大,请耐心等待...):

preview

2. 准备工作

国际惯例,正式进入主题之前让我们来先了解点预备知识。默默打开Chrome插件的官方文档,直奔我们的Tabs。可以看到它为我们提供了很多方法,而且竟然还有executeScript,这个可以说权限非常大了,不过跟我们这次的需求没啥关系。。。

2.1 query

由于我们的需求是管理tab选项卡,所以首先肯定得获取所有的tab信息。扫了一遍Methods,最相关的就是方法query

Gets all tabs that have the specified properties, or all tabs if no properties are specified.

正如官方介绍,该方法可以根据指定条件返回相应的tabs;且当不指定属性时,可以获得所有的tabs。这恰好满足我们的需求,按照API指示,我在callback中尝试打印出了拿到的tabs对象:

chrome.tabs.query({}, tabs => console.log(tabs));
[
  {
    "active": true,
    "audible": false,
    "autoDiscardable": true,
    "discarded": false,
    "favIconUrl": "https://static.clewm.net/static/images/favicon.ico",
    "height": 916,
    "highlighted": true,
    "id": 25,
    "incognito": false,
    "index": 0,
    "mutedInfo": {"muted":false},
    "pinned": true,
    "selected": true,
    "status": "complete",
    "title": "草料文本二维码生成器",
    "url": "https://cli.im/text?bb032d49e2b5fec215701da8be6326bb",
    "width": 1629,
    "windowId": 23
  },
  ...
  {
    "active": true,
    "audible": false,
    "autoDiscardable": true,
    "discarded": false,
    "favIconUrl": "https://www.google.com/images/icons/product/chrome-32.png",
    "height": 948,
    "highlighted": true,
    "id": 417,
    "incognito": false,
    "index": 0,
    "mutedInfo": {"muted": false},
    "pinned": false,
    "selected": true,
    "status": "complete",
    "title": "chrome.tabs - Google Chrome",
    "url": "https://developers.chrome.com/extensions/tabs#method-query",
    "width": 1629,
    "windowId": 812
  }
]

仔细观察不难发现,两个tab的windowId不同。这是由于我在本地同时打开了两个Chrome窗口,而这两个tab恰好在两个不同的窗口内,所以正好符合预期。

另外idindex, highlightedfavIconUrltitle等字段信息在后文中也起到非常重要的作用,相关的释义都可以在这里查看。

在构思Chrome插件UI时,为了突出当前窗口中的当前tab,我们就必须从上述数据中找出这个tab。由于每个窗口中都有一个tab是highlighted的,所以我们无法直接确定哪个tab是当前窗口的。不过,我们可以这样:

chrome.tabs.query(
  {active: true, currentWindow: true},
  tabs => console.log(tabs[0])
);

根据文档,通过指定activecurrentWindow这两个属性为true,我们就能顺利拿到当前窗口的当前tab。然后再根据tab的windowIdhighlighted进行匹配,我们就能从tabs数组中定位出哪个才是真正的当前tab了。

2.2 highlight

根据上面所述,我们已经可以拿到所有的tabs信息以及确定出哪个tab是当前窗口的当前tab,所以我们可以根据这些数据构建出一个列表。而接下来要做的就是,当用户点击其中某一项时,浏览器就能切换到所对应的tab选项卡。带着这个需求,再次翻阅文档找到了highlight

Highlights the given tabs and focuses on the first of group. Will appear to do nothing if the specified tab is currently active.

chrome.tabs.highlight({windowId, tabs});

根据该API的指示,它需要的是windowId和tab的index,而这些信息都在每个tab实体中可以拿到。不过这里有一个坑需要注意:那就是如果在当前窗口切换到另一个窗口的tab时,虽然另一个窗口的tab得以切换,但是Chrome窗口仍聚焦于当前窗口。所以需要用以下的方法,令另外的那个窗口得到聚焦:

chrome.windows.update(windowId, {focused: true});

2.3 remove

为了增强插件的实用性,我们可以在tabs列表中加入删除指定tab选项卡的功能。而在翻阅文档之后,可以确定remove可以实现我们的需求。

Closes one or more tabs.

chrome.tabs.remove(tabId);

tabId即tab数据中的id属性,因此关闭选项卡的功能实现起来也没有问题。

3. 开工

不同于插件github-star-trend,这次复杂度更高,涉及到更多的交互操作。为此,我们引入reactantdwebpack,不过整体开发起来还是比较容易的,更多的可能还是在于Chrome插件提供的API熟练度。

3.1 manifest.json

{
  "permissions": [
      "tabs"
  ],
  "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
  "browser_action": {
    "default_icon": {
      "16": "./icons/logo_16.png",
      "32": "./icons/logo_32.png",
      "48": "./icons/logo_48.png"
    },
    "default_title": "Tab Killer",
    "default_popup": "./popup.html"
  }
}
  1. 由于这次开发的插件跟tabs相关,所以我们需要在permissions字段中申请tabs权限。
  2. 由于webpack在dev模式下打包会用到eval,Chrome浏览器出于安全策略会报错,因此需要设置content_security_policy使其忽略(如果是prod模式打的包,就不需要设置)。
  3. 本次插件的交互是点击按钮弹出一个浮层,所以需要设置browser_action属性,而其default_popup字段正是我们接下来要开发的页面。

3.2 App.js

该文件是我们的核心文件之一,主要负责tabs数据的获取和处理等维护工作。

根据API文档所示,获取tabs数据是一个异步操作,我们在其回调函数中才能拿到。这也意味着我们的应用一开始应该是处于一个LOADING的状态,拿到数据之后成为OK状态,另外再考虑到异常情况(例如无数据或出错),我们�可以将其定义为EXCEPTION状态。

class App extends React.PureComponent {

  state = {
    tabsData: [],
    status: STATUS.LOADING
  }

  componentDidMount() {
    this.getTabsData();
  }

  getTabsData() {
    Promise.all([
      this.getAllTabs(),
      this.getCurrentTab(),
      Helper.waitFor(300),
    ]).then(([allTabs, currentTab]) => {
      const tabsData = Helper.convertTabsData(allTabs, currentTab);
      if(tabsData.length > 0) {
        this.setState({tabsData, status: STATUS.OK});
      } else {
        this.setState({tabsData: [], status: STATUS.EXCEPTION});
      }
    }).catch(err => {
      this.setState({tabsData: [], status: STATUS.EXCEPTION});
      console.log('get tabs data failed, the error is:', err.message);
    });
  }

  getAllTabs = () => new Promise(resolve => chrome.tabs.query({}, tabs => resolve(tabs)))

  getCurrentTab = () => new Promise(resolve => chrome.tabs.query({active: true, currentWindow: true}, tabs => resolve(tabs[0])))

  render() {
    const {status, tabsData} = this.state;
    return (
      <div className="app-container">
        <TabsList data={tabsData} status={status}/>
      </div>
    );
  }
}

const Helper = {
  waitFor(timeout) {
    return new Promise(resolve => {
      setTimeout(resolve, timeout);
    });
  },
  convertTabsData() {}
}

思路很简单,就是在didMount的时候获取tabs数据,不过我们在这里用到Promise.all来控制异步操作。

由于获取tabs数据这一操作是异步的,不同电脑,不同状态,不同tab数量时该操作的耗时都可能不同,所以为了更好的用户体验,我们可以在一开始用antd的Spin组件来充当占位符。需要注意的是,如果获取tabs数据非常快,Loading动画会有一闪而过的感觉,并不十分友好。因此我们用个300ms的promise搭配Promise.all使用,可以保证至少300ms的Loading动画。

接下来就是拿到tabs数据之后的convert工作。

Chrome提供的API获取到的数据是一个扁平的数组,不同窗口内的tab也被混在同一个数组内。我们更希望能按窗口进行分组,这样在浏览和查找时对用户更直观,操作更方便,用户体验更好。所以我们需要对tabsData进行一次转换:

data convert

convertTabsData(allTabs = [], currentTab = {}) {

  // 过滤非法数据
  if(!(allTabs.length > 0 && currentTab.windowId !== undefined)) {
    return [];
  }

  // 按windowId进行分组归类
  const hash = Object.create(null);
  for(const tab of allTabs) {
    if(!hash[tab.windowId]) {
      hash[tab.windowId] = [];
    }
    hash[tab.windowId].push(tab);
  }

  // 将obj转成array
  const data = [];
  Object.keys(hash).forEach(key => data.push({
    tabs: hash[key],
    windowId: Number(key),
    isCurWindow: Number(key) === currentTab.windowId
  }));

  // 进行排序,将当前窗口的顺序往上提,保证更好的体验
  data.sort((winA, winB) => {
    if(winA.isCurWindow) {
      return -1;
    } else if(winB.isCurWindow) {
      return 1;
    } else {
      return 0;
    }
  });

  return data;
}

3.3 TabList.js

根据App.js中的设计,我们可以先搭起代码的骨架:

export class TabsList extends React.PureComponent {

  renderLoading() {
    return (
      <div className={'loading-container'}>
        <Spin size="large"/>
      </div>
    );
  }

  renderOK() {
    // TODO...
  }

  renderException() {
    return (
      <div className={'no-result-container'}>
        <Empty description={'没有数据哎~'}/>
      </div>
    );
  }

  render() {
    const {status} = this.props;
    switch(status) {
      case STATUS.LOADING:
        return this.renderLoading();
      case STATUS.OK:
        return this.renderOK();
      case STATUS.EXCEPTION:
      default:
        return this.renderException();
    }
  }
}

接下来就是renderOK的实现,由于没有固定的设计稿,我们可以尽情发挥自己的想象。这里借助antd粗略地实现了一版交互(加入了切换tab、搜索和删除等操作),具体代码考虑到篇幅就不贴了,感兴趣的可以进这里查看。

4. 完结

整个插件的制作过程,到这儿就已经完了。如果你有更好的idea或设计,可以提PR哦~通过这次学习,熟悉了对Tabs的操作,同时对Chrome插件的制作流程也算是有了更深的感悟。

5. 参考

小哥哥小姐姐看过来,这里有个组件库需要您签收一下

1. 前言

一直以来都想做个组件库,一方面是对工作中常遇问题的总结,另一方面也确实能够提升工作效率(谁又不想造一个属于自己的轮子呢~),于是乎就有了本文的主角儿rn-components-kit

市面上web的UI组件库如此之多,react相关的有antdvue相关的有element。不过,今天介绍的是react-native的一个组件库。不同于上述组件库都有统一的视觉规范,rn-components-kit更注重的是在提供组件基本能力的同时尽可能多地赋予自定义样式的可能性。

放上仓库地址,欢迎star,欢迎提issue,欢迎提PR~

下面就让我们来认识一下rn-components-kit~

2. 快速开始

2.1 安装

你可以通过下面的命令安装rn-components-kit:

npm install rn-components-kit --save
import React from 'react';
import {Badge} from ' @rn-components-kit/badge';

const TestComponent = () => <Badge/>;

2.2 按需加载

上述的方法将会把所有的组件打进bundle内,即使你没有用到所有的组件。如果你想减少包大小,你可以这样引入:

npm install @rn-components-kit/badge --save
import React from 'react';
import {Badge} from ' @rn-components-kit/badge';

const TestComponent = () => <Badge/>;

事实上,每个组件都是支持单独安装的,我们也推荐你使用这种方式引入组件。

2.3 运行示例

我们创建了一个app专门用来演示每个组件的使用方法以及运行效果,你可以点击这里查看示例代码。如果你想运行这个例子,你需要先下载本仓库到本地。

# download repo
git clone https://github.com/SmallStoneSK/rn-components-kit.git

# install dependencies
npm install

# for iOS
react-native run-ios

# for android
react-native run-android

以下是运行示例app后各界面的截图:

3. 组件

3.1 Badge

图标右上角的圆形徽标数字。支持以下特性:

  • 纯圆点和带文字圆点两种样式
  • 自定义颜色
  • 友好的过渡动画
npm install @rn-components-kit/badge --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code
Demo4 Code

3.2 Button

按钮组件,支持以下特性:

  • defaultprimarysuccesswarningdanger5种主题
  • smalldefaultlarge3种大小
  • squaredefaultround3种形状
  • 支持icon按钮和控制图标位置
  • 支持outline样式按钮
  • 支持block样式按钮
  • 支持link样式按钮
npm install @rn-components-kit/button --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code
Demo4 Code
Demo5 Code
Demo6 Code
Demo7 Code

3.3 Carousel

轮播组件,就像"旋转木马"一样。支持以下特性:

  • 水平/垂直两个方向
  • 循环模式
  • 自动播放模式
  • 居中模式,当前项会被调整至一屏的中间,同时前一项/后一项也会露出一部分
  • 支持轮播内容不足一屏的长度

注意

  1. 当使用水平模式时,widthitemWidth必须设置。
  2. 当使用垂直模式时,heightitemHeight必须设置。
  3. 如果轮播组件内容的数据源(数组)是会变化的,需要设置数据源作为data属性,不然轮播组件中的内容将不会更新。
  4. 下面的图片将有助于理解一些样式上的重要变量含义:

npm install @rn-components-kit/carousel --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code
Demo4 Code
Demo5 Code
Demo6 Code
Demo7 Code

3.4 CheckBox

复选框组件。

npm install @rn-components-kit/checkbox --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code
Demo4 Code

3.5 DeckSwiper

DeckSwiper让你一次评估一个选项,而不是从一组选项中进行选择。

npm install @rn-components-kit/deck-swiper --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code

3.6 Divider

分割线组件,支持两种方向: horizontalvertical.

npm install @rn-components-kit/divider --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code

3.7 Icon

语义化的矢量图形。支持以下特性:

注意:确保你的项目已经集成了ART模块

如果你遇到诸如No component found for view with name "ARTXXX"之类的报错,那是因为你的项目还没有集成ART模块。你需要:

  1. 使用Xcode打开项目下的ios工程,Libraries -> Add Files to -> node_modules/react-native/Libraries/ART/ART.xcodeproj
  2. 点击项目根目录,找到Linked Frameworks and Libraries,点击+选择libART.a,然后重新编译工程。
  3. 重新编译完成后,重新运行命令react-native run-ios/android,重启项目。
npm install @rn-components-kit/icon --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code

3.8 Progress

进度条组件,展示当前操作进度,支持以下特性:

  • linecircle两种类型
  • normalactivesuccessfail四种状态
  • 自定义颜色,支持线性渐变(目前仅限line类型)
  • 自定义进度文案格式,甚至支持信息展示区域完全自定义

注意

由于本组件支持线性渐变选项,所以你的项目需要集成react-native-linear-gradient。如果你的项目还没集成,你可以参照这里的指示完成。

npm install @rn-components-kit/progress --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code
Demo4 Code
Demo5 Code

3.9 Radio

Radio组件让用户从一堆选项中选择一项,支持以下特性:

  • 禁用点击
  • 自定义选中/未选中icon或图片
  • 状态切换时有过渡动画
npm install @rn-components-kit/radio --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code
Demo4 Code

3.10 Rating

评分组件,支持以下特性:

  • 支持点选滑动操作进行评分
  • 自定义图标样式(包括类型颜色大小
  • 支持不同的滑动步长(例如:0.1/0.2/0.5/1)
npm install @rn-components-kit/rating --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code

3.11 ScrollPicker

滚动选择器,支持以下特性:

  • 抹平AndroidiOS平台的交互差异
  • 支持多项选择器
  • 支持级联选择
  • ScrollPicker.Item支持自定义选项内容
npm install @rn-components-kit/scroll-picker --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code

3.12 Skeleton

骨架屏,常在loading时起占位的作用,支持以下特性:

  • avatartitleparagraph 三部分均支持定制化
  • 可以使用高阶组件withSkeleton完全定制化骨架屏的组成和样式

注意

当你使用装饰器的语法使用高阶组件withSkeleton时,确保你的项目安装了插件@babel/plugin-proposal-decorators.

npm install @rn-components-kit/skeleton --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code

3.13 Slider

以滑动的交互形式,从指定范围内选择值。支持以下特性:

  • 水平垂直两种方向
  • 12个滑块
  • 滑块和轨道样式高度可定制化
  • tip文案可定制化
npm install @rn-components-kit/slider --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code
Demo4 Code

3.14 Spin

用于展示页面或区块的加载中状态。支持以下7种不同动画类型:

  • Ladder
  • Rainbow
  • Wave
  • RollingCubes
  • ChasingCircles
  • Pulse
  • FlippingCard
npm install @rn-components-kit/spin --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code
Demo4 Code
Demo5 Code
Demo6 Code
Demo7 Code

3.15 SwipeOut

iOS样式的滑动隐藏按钮组件,支持以下特性:

  • 支持两个方向滑出
  • 隐藏部分支持多个按钮配置
  • 隐藏部分完全自定义
npm install @rn-components-kit/swipe-out --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code

3.16 Switch

开关选择器,支持以下特性:

  • 自定义颜色
  • 自定义大小
  • 两种风格: cupertinomaterial
npm install @rn-components-kit/switch --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code
Demo4 Code
Demo5 Code

3.17 Tag

进行标记和分类的小标签。支持以下特性:

  • 自定义颜色
  • 支持两种风格:outlinesolid
  • 可关闭及其关闭事件回调函数
npm install @rn-components-kit/tag --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code
Demo2 Code
Demo3 Code

3.18 Tooltip

当用户点击某个元素,展示一个气泡框,支持以下特性:

  • 气泡框支持topbottom两个方向
  • 完全自定义气泡框内容
npm install @rn-components-kit/tooltip --save

详细API请查看文档

组件示例预览效果 代码
Demo1 Code

4. 写在最后

最后再次放上仓库地址,欢迎star,欢迎提issue,欢迎提PR~

你也可以关注我的Blog,欢迎一起交流学习~

用Flutter构建漂亮的UI界面 - 基础组件篇

1. 前言

Flutter作为时下最流行的技术之一,凭借其出色的性能以及抹平多端的差异优势,早已引起大批技术爱好者的关注,甚至一些闲鱼美团腾讯等大公司均已开始使用。虽然目前其生态还没有完全成熟,但身靠背后的Google加持,其发展速度已经足够惊人,可以预见将来对Flutter开发人员的需求也会随之增长。

无论是为了现在的技术尝鲜还是将来的潮流趋势,都9102年了,作为一个前端开发者,似乎没有理由不去尝试它。正是带着这样的心理,笔者也开始学习Flutter,同时建了一个用于练习的仓库,后续所有代码都会托管在上面,欢迎star,一起学习。

今天分享的是Flutter中最常用到的一些基础组件,它们是构成UI界面的基础元素:容器绝对定位布局文本图片图标等。

cover.png

2. 基础组件

2.1 Container(容器组件)

Container组件是最常用的布局组件之一,可以认为它是web开发中的div,rn开发中的View。其往往可以用来控制大小、背景颜色、边框、阴影、内外边距和内容排列方式等。我们先来看下其构造函数:

Container({
  Key key,
  double width,
  double height,
  this.margin,
  this.padding,
  Color color,
  this.alignment,
  BoxConstraints constraints,
  Decoration decoration,
  this.foregroundDecoration,
  this.transform,
  this.child,
})

2.1.1 widthheightmarginpadding

这些属性的含义和我们已经熟知的并没有区别。唯一需要注意的是,marginpadding的赋值不是一个简单的数字,因为其有left, top, right, bottom四个方向的值需要设置。Flutter提供了EdgeInsets这个类,帮助我们方便地生成四个方向的值。通常情况下,我们可能会用到EdgeInsets的4种构造方法:

  • EdgeInsets.all(value): 用于设置4个方向一样的值;
  • EdgeInsets.only(left: val1, top: val2, right: val3, bottom: val4): 可以单独设置某个方向的值;
  • EdgeInsets.symmetric(horizontal: val1, vertical: val2): 用于设置水平/垂直方向上的值;
  • EdgeInsets.fromLTRB(left, top, right, bottom): 按照左上右下的顺序设置4个方向的值。

2.1.2 color

该属性的含义是背景颜色,等同于web/rn中的backgroundColor。需要注意的是Flutter中有一个专门表示颜色的Color类,而非我们常用的字符串。不过我们可以非常轻松地进行转换,举个栗子:

在web/rn中我们会用'#FF0000''red'来表示红色,而在Flutter中,我们可以用Color(0xFFFF0000)Colors.red来表示。

2.1.3 alignment

该属性是用来决定Container组件的子组件将以何种方式进行排列(PS:再也不用为怎么居中操心了)。其可选值通常会用到:

  • Alignment.topLeft: 左上
  • Alignment.topCenter: 上中
  • Alignment.topRight: 右上
  • Alignment.centerLeft: 左中
  • Alignment.center: 居中
  • Alignment.centerRight: 右中
  • Alignment.bottomLeft: 左下
  • Alignment.bottomCenter: 下中
  • Alignment.bottomRight: 右下

2.1.4 constraints

在web/rn中我们通常会用minWidth/maxWidth/minHeight/maxHeight等属性来限制容器的宽高。在Flutter中,你需要使用BoxConstraints(盒约束)来实现该功能。

// 容器的大小将被限制在[100*100 ~ 200*200]内
BoxConstraints(
  minWidth: 100,
  maxWidth: 200,
  minHeight: 100,
  maxHeight: 200,
)

2.1.5 decoration

该属性非常强大,字面意思是装饰,因为通过它你可以设置边框阴影渐变圆角等常用属性。BoxDecoration继承自Decoration类,因此我们通常会生成一个BoxDecoration实例来设置这些属性。

1) 边框

可以用Border.all构造函数直接生成4条边框,也可以用Border构造函数单独设置不同方向上的边框。不过令人惊讶的是官方提供的边框竟然不支持虚线issue在这里)。

// 同时设置4条边框:1px粗细的黑色实线边框
BoxDecoration(
  border: Border.all(color: Colors.black, width: 1, style: BorderStyle.solid)
)

// 设置单边框:上边框为1px粗细的黑色实线边框,右边框为1px粗细的红色实线边框
BoxDecoration(
  border: Border(
    top: BorderSide(color: Colors.black, width: 1, style: BorderStyle.solid),
    right: BorderSide(color: Colors.red, width: 1, style: BorderStyle.solid),
  ),
)

2) 阴影

阴影属性和web中的boxShadow几乎没有区别,可以指定xyblurspreadcolor等属性。

BoxDecoration(
  boxShadow: [
    BoxShadow(
      offset: Offset(0, 0),
      blurRadius: 6,
      spreadRadius: 10,
      color: Color.fromARGB(20, 0, 0, 0),
    ),
  ],
)

3) 渐变

如果你不想容器的背景颜色是单调的,可以尝试用gradient属性。Flutter同时支持线性渐变径向渐变

// 从左到右,红色到蓝色的线性渐变
BoxDecoration(
  gradient: LinearGradient(
    begin: Alignment.centerLeft,
    end: Alignment.centerRight,
    colors: [Colors.red, Colors.blue],
  ),
)

// 从中心向四周扩散,红色到蓝色的径向渐变
BoxDecoration(
  gradient: RadialGradient(
    center: Alignment.center,
    colors: [Colors.red, Colors.blue],
  ),
)

4) 圆角

通常情况下,你可能会用到BorderRadius.circular构造函数来同时设置4个角的圆角,或是BorderRadius.only构造函数来单独设置某几个角的圆角:

// 同时设置4个角的圆角为5
BoxDecoration(
  borderRadius: BorderRadius.circular(5),
)

// 设置单圆角:左上角的圆角为5,右上角的圆角为10
BoxDecoration(
  borderRadius: BorderRadius.only(
    topLeft: Radius.circular(5),
    topRight: Radius.circular(10),
  ),
)

2.1.6 transform

transform属性和我们在web/rn中经常用到的基本也没有差别,主要包括:平移缩放旋转倾斜。在Flutter中,封装了矩阵变换类Matrix4帮助我们进行变换:

  • translationValues(x, y, z): 平移x, y, z;
  • rotationX(radians): x轴旋转radians弧度;
  • rotationY(radians): y轴旋转radians弧度;
  • rotationZ(radians): z轴旋转radians弧度;
  • skew(alpha, beta): x轴倾斜alpha度,y轴倾斜beta度;
  • skewX(alpha): x轴倾斜alpha度;
  • skewY(beta): y轴倾斜beta度;

2.1.7 小结

Container组件的属性很丰富,虽然有些用法上和web/rn有些许差异,但基本上大同小异,所以过渡起来也不会有什么障碍。另外,由于Container组件是单子节点组件,也就是只允许子节点有一个。所以在布局上,很多时候我们会用RowColumn组件进行/布局。

2.2 Row/Column(行/列组件)

RowColumn组件其实和web/rn中的Flex布局(弹性盒子)特别相似,或者我们可以就这么理解。使用Flex布局的同学对主轴次轴的概念肯定都已经十分熟悉,Row组件的主轴就是横向,Column组件的主轴就是纵向。且它们的构造函数十分相似(已省略不常用属性):

Row({
  Key key,
  MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
  CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
  MainAxisSize mainAxisSize = MainAxisSize.max,
  List<Widget> children = const <Widget>[],
})

Column({
  Key key,
  MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
  CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
  MainAxisSize mainAxisSize = MainAxisSize.max,
  List<Widget> children = const <Widget>[],
})

2.2.1 mainAxisAlignment

该属性的含义是主轴排列方式,根据上述构造函数可以知道RowColumn组件在主轴方向上默认都是从start开始,也就是说Row组件默认从左到右开始排列子组件,Column组件默认从上到下开始排列子组件。

当然,你还可以使用其他的可选值:

  • MainAxisAlignment.start
  • MainAxisAlignment.end
  • MainAxisAlignment.center
  • MainAxisAlignment.spaceBetween
  • MainAxisAlignment.spaceAround
  • MainAxisAlignment.spaceEvenly

2.2.2 crossAxisAlignment

该属性的含义是次轴排列方式,根据上述构造函数可以知道RowColumn组件在次轴方向上默认都是居中。

这里有一点需要特别注意:由于Column组件次轴方向上(即水平)默认是居中对齐,所以水平方向上不会撑满其父容器,此时需要指定CrossAxisAlignment.stretch才可以。

另外,crossAxisAlignment其他的可选值有:

  • crossAxisAlignment.start
  • crossAxisAlignment.end
  • crossAxisAlignment.center
  • crossAxisAlignment.stretch
  • crossAxisAlignment.baseline

2.2.3 mainAxisSize

字面意思上来说,该属性指的是在主轴上的尺寸。其实就是指在主轴方向上,是包裹其内容,还是撑满其父容器。它的可选值有MainAxisSize.minMainAxisSize.max。由于其默认值都是MainAxisSize.max,所以主轴方向上默认大小都是尽可能撑满父容器的。

2.2.4 小结

由于Row/Column组件和我们熟悉的Flex布局非常相似,所以上手起来非常容易,几乎零学习成本。

2.3 Stack/Positoned(绝对定位布局组件)

绝对定位布局在web/rn开发中也是使用频率较高的一种布局方式,Flutter也提供了相应的组件实现,需要将StackPositioned组件搭配在一起使用。比如下方的这个例子就是创建了一个黄色的盒子,并且在其四个角落放置了4个红色的小正方形。Stack组件就是绝对定位的容器,Positioned组件通过lefttop rightbottom四个方向上的属性值来决定其在父容器中的位置。

Container(
  height: 100,
  color: Colors.yellow,
  child: Stack(
    children: <Widget>[
      Positioned(
        left: 10,
        top: 10,
        child: Container(width: 10, height: 10, color: Colors.red),
      ),
      Positioned(
        right: 10,
        top: 10,
        child: Container(width: 10, height: 10, color: Colors.red),
      ),
      Positioned(
        left: 10,
        bottom: 10,
        child: Container(width: 10, height: 10, color: Colors.red),
      ),
      Positioned(
        right: 10,
        bottom: 10,
        child: Container(width: 10, height: 10, color: Colors.red),
      ),
    ],
  ),
)

2.4 Text(文本组件)

Text组件也是日常开发中最常用的基础组件之一,我们通常用它来展示文本信息。来看下其构造函数(已省略不常用属性):

const Text(
  this.data, {
  Key key,
  this.style,
  this.textAlign,
  this.softWrap,
  this.overflow,
  this.maxLines,
})
  • data: 显示的文本信息;
  • style: 文本样式,Flutter提供了一个TextStyle类,最常用的fontSizefontWeightcolorbackgroundColorshadows等属性都是通过它设置的;
  • textAlign: 文字对齐方式,常用可选值有TextAlignleftrightcenterjustify
  • softWrap: 文字是否换行;
  • overflow: 当文字溢出的时候,以何种方式处理(默认直接截断)。可选值有TextOverflowclipfadeellipsisvisible
  • maxLines: 当文字超过最大行数还没显示完的时候,就会根据overflow属性决定如何截断处理。

FlutterText组件足够灵活,提供了各种属性让我们定制,不过一般情况下,我们更多地只需下方几行代码就足够了:

Text(
  '这是测试文本',
  style: TextStyle(
    fontSize: 13,
    fontWeight: FontWeight.bold,
    color: Color(0xFF999999),
  ),
)

除了上述的应用场景外,有时我们还会遇到富文本的需求(即一段文本中,可能需要不同的字体样式)。比如在一些UI设计中经常会遇到表示价格的时候,符号比金额的字号小点。对于此类需求,我们可以用Flutter提供的Text.rich构造函数来创建相应的文本组件:

Text.rich(TextSpan(
  children: [
    TextSpan(
      '¥',
      style: TextStyle(
        fontSize: 12,
        color: Color(0xFFFF7528),
      ),
    ),
    TextSpan(
      '258',
      style: TextStyle(
        fontSize: 15,
        color: Color(0xFFFF7528),
      ),
    ),
  ]
))

2.5 Image(图片组件)

Image图片组件作为丰富内容的基础组件之一,日常开发中的使用频率也非常高。看下其构造函数(已省略不常用属性):

Image({
  Key key,
  @required this.image,
  this.width,
  this.height,
  this.color,
  this.fit,
  this.repeat = ImageRepeat.noRepeat,
})
  • image: 图片源,最常用到主要有两种(AssetImageNetworkImage)。使用AssetImage之前,需要在pubspec.yaml文件中声明好图片资源,然后才能使用;而NextworkImage指定图片的网络地址即可,主要是在加载一些网络图片时会用到;
  • width: 图片宽度;
  • height: 图片高度;
  • color: 图片的背景颜色,当网络图片未加载完毕之前,会显示该背景颜色;
  • fit: 当我们希望图片根据容器大小进行适配而不是指定固定的宽高值时,可以通过该属性来实现。其可选值有BoxFitfillcontaincoverfitWidthfitHeightnonescaleDown
  • repeat: 决定当图片实际大小不足指定大小时是否使用重复效果。

另外,Flutter还提供了Image.networkImage.asset构造函数,其实是语法糖。比如下方的两段代码结果是完全一样的:

Image(
  image: NetworkImage('https://ss1.bdstatic.com/70cFuXSh_Q1YnxGkpoWK1HF6hhy/it/u=1402367109,4157195964&fm=27&gp=0.jpg'),
  width: 100,
  height: 100,
)

Image.network(
  'https://ss1.bdstatic.com/70cFuXSh_Q1YnxGkpoWK1HF6hhy/it/u=1402367109,4157195964&fm=27&gp=0.jpg',
  width: 100,
  height: 100,
)

2.6 Icon(图标组件)

Icon图标组件相比于图片有着放大不会失真的优势,在日常开发中也是经常会被用到。Flutter更是直接内置了一套Material风格的图标(你可以在这里预览所有的图标类型)。看下构造函数:

const Icon(
  this.icon, {
  Key key,
  this.size,
  this.color,
})
  • icon: 图标类型;
  • size: 图标大小;
  • color: 图标颜色。

3. 布局实战

通过上一节的介绍,我们对ContainerRowColumnStackPositionedTextImageIcon组件有了初步的认识。接下来,就让我们通过一个实际的例子来加深理解和记忆。

3.1 准备工作 - 数据类型

根据上述卡片中的内容,我们可以定义一些字段。为了规范开发流程,我们先给卡片定义一个数据类型的类,这样在后续的开发过程中也能更好地对数据进行Mock和管理:

class PetCardViewModel {
  /// 封面地址
  final String coverUrl;

  /// 用户头像地址
  final String userImgUrl;

  /// 用户名
  final String userName;

  /// 用户描述
  final String description;

  /// 话题
  final String topic;

  /// 发布时间
  final String publishTime;

  /// 发布内容
  final String publishContent;

  /// 回复数量
  final int replies;

  /// 喜欢数量
  final int likes;

  /// 分享数量
  final int shares;

  const PetCardViewModel({
    this.coverUrl,
    this.userImgUrl,
    this.userName,
    this.description,
    this.topic,
    this.publishTime,
    this.publishContent,
    this.replies,
    this.likes,
    this.shares,
  });
}

3.2 搭建骨架,布局拆分

根据给的视觉图,我们可以将整体进行拆分,一共划分成4个部分:CoverUserInfoPublishContentInteractionArea。为此,我们可以搭起代码的基本骨架:

class PetCard extends StatelessWidget {
  final PetCardViewModel data;

  const PetCard({
    Key key,
    this.data,
  }) : super(key: key);

  Widget renderCover() {
    
  }

  Widget renderUserInfo() {
    
  }

  Widget renderPublishContent() {
  
  }

  Widget renderInteractionArea() {
   
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(8),
        boxShadow: [
          BoxShadow(
            blurRadius: 6,
            spreadRadius: 4,
            color: Color.fromARGB(20, 0, 0, 0),
          ),
        ],
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          this.renderCover(),
          this.renderUserInfo(),
          this.renderPublishContent(),
          this.renderInteractionArea(),
        ],
      ),
    );
  }
}

3.3 封面区域

为了更好的凸现图片的效果,这里加了一个蒙层,所以此处刚好可以用得上Stack/Positioned布局和LinearGradient渐变,Dom结构如下:

cover

Widget renderCover() {
  return Stack(
    fit: StackFit.passthrough,
    children: <Widget>[
      ClipRRect(
        borderRadius: BorderRadius.only(
          topLeft: Radius.circular(8),
          topRight: Radius.circular(8),
        ),
        child: Image.network(
          data.coverUrl,
          height: 200,
          fit: BoxFit.fitWidth,
        ),
      ),
      Positioned(
        left: 0,
        top: 100,
        right: 0,
        bottom: 0,
        child: Container(
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                Color.fromARGB(0, 0, 0, 0),
                Color.fromARGB(80, 0, 0, 0),
              ],
            ),
          ),
        ),
      ),
    ],
  );
}

3.4 用户信息区域

用户信息区域就非常适合使用RowColumn组件来进行布局,Dom结构如下:

user-info

Widget renderUserInfo() {
  return Container(
    margin: EdgeInsets.only(top: 16),
    padding: EdgeInsets.symmetric(horizontal: 16),
    child: Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: <Widget>[
        Row(
          children: <Widget>[
            CircleAvatar(
              radius: 20,
              backgroundColor: Color(0xFFCCCCCC),
              backgroundImage: NetworkImage(data.userImgUrl),
            ),
            Padding(padding: EdgeInsets.only(left: 8)),
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  data.userName,
                  style: TextStyle(
                    fontSize: 15,
                    fontWeight: FontWeight.bold,
                    color: Color(0xFF333333),
                  ),
                ),
                Padding(padding: EdgeInsets.only(top: 2)),
                Text(
                  data.description,
                  style: TextStyle(
                    fontSize: 12,
                    color: Color(0xFF999999),
                  ),
                ),
              ],
            ),
          ],
        ),
        Text(
          data.publishTime,
          style: TextStyle(
            fontSize: 13,
            color: Color(0xFF999999),
          ),
        ),
      ],
    ),
  );
}

3.5 发布内容区域

通过这块区域的UI练习,我们可以实践Container组件设置不同的borderRadius,以及Text组件文本内容超出时的截断处理,Dom结构如下:

publish-content

Widget renderPublishContent() {
  return Container(
    margin: EdgeInsets.only(top: 16),
    padding: EdgeInsets.symmetric(horizontal: 16),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Container(
          margin: EdgeInsets.only(bottom: 14),
          padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
          decoration: BoxDecoration(
            color: Color(0xFFFFC600),
            borderRadius: BorderRadius.only(
              topRight: Radius.circular(8),
              bottomLeft: Radius.circular(8),
              bottomRight: Radius.circular(8),
            ),
          ),
          child: Text(
            '# ${data.topic}',
            style: TextStyle(
              fontSize: 12,
              color: Colors.white,
            ),
          ),
        ),
        Text(
          data.publishContent,
          maxLines: 2,
          overflow: TextOverflow.ellipsis,
          style: TextStyle(
            fontSize: 15,
            fontWeight: FontWeight.bold,
            color: Color(0xFF333333),
          ),
        ),
      ],
    ),
  );
}

3.6 互动区域

在这个模块,我们会用到Icon图标组件,可以控制其大小和颜色等属性,Dom结构如下:

interaction-area

Widget renderInteractionArea() {
  return Container(
    margin: EdgeInsets.symmetric(vertical: 16),
    padding: EdgeInsets.symmetric(horizontal: 16),
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: <Widget>[
        Row(
          children: <Widget>[
            Icon(
              Icons.message,
              size: 16,
              color: Color(0xFF999999),
            ),
            Padding(padding: EdgeInsets.only(left: 6)),
            Text(
              data.replies.toString(),
              style: TextStyle(
                fontSize: 15,
                color: Color(0xFF999999),
              ),
            ),
          ],
        ),
        Row(
          children: <Widget>[
            Icon(
              Icons.favorite,
              size: 16,
              color: Color(0xFFFFC600),
            ),
            Padding(padding: EdgeInsets.only(left: 6)),
            Text(
              data.likes.toString(),
              style: TextStyle(
                fontSize: 15,
                color: Color(0xFF999999),
              ),
            ),
          ],
        ),
        Row(
          children: <Widget>[
            Icon(
              Icons.share,
              size: 16,
              color: Color(0xFF999999),
            ),
            Padding(padding: EdgeInsets.only(left: 6)),
            Text(
              data.shares.toString(),
              style: TextStyle(
                fontSize: 15,
                color: Color(0xFF999999),
              ),
            ),
          ],
        ),
      ],
    ),
  );
}

3.7 小结

通过上面的一个例子,我们成功地把一个看起来复杂的UI界面一步步拆解,将之前提到的组件都用了个遍,并且最终得到了不错的效果。其实,日常开发中90%以上的需求都离不开上述提到的基础组件。因此,只要稍加练习,熟悉了Flutter中的基础组件用法,就已经算是迈出了一大步哦~

这里还有银行卡朋友圈的UI练习例子,由于篇幅原因就不贴代码了,可以去github仓库看。

4. 总结

本文首先介绍了Flutter中构建UI界面最常用的基础组件(容器绝对定位布局文本图片图标)用法。接着,介绍了一个较复杂的UI实战例子。通过对Dom结构的层层拆解,前文提到过的组件得到一个综合运用,也算是巩固了前面所学的概念知识。

不过最后不得不吐槽一句:Flutter的嵌套真的很难受。。。如果不对UI布局进行模块拆分,那绝对是噩梦般的体验。而且不像web/rn开发样式可以单独抽离,Flutter这种将样式当做属性的处理方式,一眼看去真的很难理清dom结构,对于新接手代码的开发人员而言,需要费点时间理解。。。

高阶组件 + New Context API = ?

1. 前言

继上次小试牛刀尝到高价组件的甜头之后,现已深陷其中无法自拔。。。那么这次又会带来什么呢?今天,我们就来看看【高阶组件】和【New Context API】能擦出什么火花!

2. New Context API

Context API其实早就存在,大名鼎鼎的redux状态管理库就用到了它。合理地利用Context API,我们可以从Prop Drilling的痛苦中解脱出来。但是老版的Context API存在一个严重的问题:子孙组件可能不更新。

举个栗子:假设存在组件引用关系A -> B -> C,其中子孙组件C用到祖先组件A中Context的属性a。其中,某一时刻属性a发生变化导致组件A触发了一次渲染,但是由于组件B是PureComponent且并未用到属性a,所以a的变化不会触发B及其子孙组件的更新,导致组件C未能得到及时的更新。

好在[email protected]中推出的New Context API已经解决了这一问题,而且在使用上比原来的也更优雅。因此,现在我们可以放心大胆地使用起来。说了那么多,都不如一个实际的例子来得实在。Show me the code:

// DemoContext.js
import React from 'react';
export const demoContext = React.createContext();

// Demo.js
import React from 'react';
import { ThemeApp } from './ThemeApp';
import { CounterApp } from './CounterApp';
import { demoContext } from './DemoContext';

export class Demo extends React.PureComponent {
  state = { count: 1, theme: 'red' };
  onChangeCount = newCount => this.setState({ count: newCount });
  onChangeTheme = newTheme => this.setState({ theme: newTheme });
  render() {
    console.log('render Demo');
    return (
      <demoContext.Provider value={{
        ...this.state,
        onChangeCount: this.onChangeCount,
        onChangeTheme: this.onChangeTheme
      }}>
        <CounterApp />
        <ThemeApp />
      </demoContext.Provider>
    );
  }
}

// CounterApp.js
import React from 'react';
import { demoContext } from './DemoContext';

export class CounterApp extends React.PureComponent {
  render() {
    console.log('render CounterApp');
    return (
      <div>
        <h3>This is Counter application.</h3>
        <Counter />
      </div>
    );
  }
}

class Counter extends React.PureComponent {
  render() {
    console.log('render Counter');
    return (
      <demoContext.Consumer>
        {data => {
          const { count, onChangeCount } = data;
          console.log('render Counter consumer');
          return (
            <div>
              <button onClick={() => onChangeCount(count - 1)}>-</button>
              <span style={{ margin: '0 10px' }}>{count}</span>
              <button onClick={() => onChangeCount(count + 1)}>+</button>
            </div>
          );
        }}
      </demoContext.Consumer>
    );
  }
}

// ThemeApp.js
import React from 'react';
import { demoContext } from './DemoContext';

export class ThemeApp extends React.PureComponent {
  render() {
    console.log('render ThemeApp');
    return (
      <div>
        <h3>This is Theme application.</h3>
        <Theme />
      </div>
    );
  }
}

class Theme extends React.PureComponent {
  render() {
    console.log('render Theme');
    return (
      <demoContext.Consumer>
        {data => {
          const {theme, onChangeTheme} = data;
          console.log('render Theme consumer');
          return (
            <div>
              <div style={{ width: '100px', height: '30px', margin: '0 auto', backgroundColor: theme }} />
              <select style={{ marginTop: '20px' }} onChange={evt => onChangeTheme(evt.target.value)}>
                {['red', 'green', 'yellow', 'blue'].map(item => <option key={item}>{item}</option>)}
              </select>
            </div>
          );
        }}
      </demoContext.Consumer>
    );
  }
}

虽说一上来就贴个百来行代码的这种行为有点low,但是为了介绍New Context API的基本用法,也只能这样了。。。不过啊,上面的例子其实很简单,就算是先对New Context API的使用方法来个简单的科普吧~

仔细观察上面的代码不难发现组件间的层级关系,即:Demo -> CounterApp -> Counter 和 Demo -> ThemeApp -> Theme,且中间组件CounterApp和CounterApp并没有作为媒介来传递count和theme值。接下来,我们就来分析下上面的代码,看看如何使用New Context API来实现祖先->子孙传值的:

  1. New Context API在React中提供了一个React.createContext方法,它返回的对象中包含了ProviderConsumer两个方法。也就是DemoContext.js中的代码。
  2. 顾名思义,Provider可以理解为公用值的一个提供者,而Consumer就是这个公用值的消费者。那么两者是如何联系起来的呢?注意Provider接收的value参数。Provider会将这个value原封不动地传给Consumer,这点也可以从Demo.js/CounterApp.js/ThemeApp.js三个文件中体现出来。
  3. 再仔细观察例子中的value参数,它是一个对象,key分别是count, theme, onChangeCount, onChangeTheme。很显然,在Consumer中,我们不但可以使用count和theme,还可以使用onChangeCount和onChangeTheme来分别修改相应的state,从而导致整个应用状态的更新和重新渲染。

下面我们再来看看实际运行效果。从下图中我们可以清楚地看到,CounterApp中的number和ThemeApp中的color都能正常地响应我们的操作,说明New Context API确实达到了我们预期的效果。除此之外,不妨再仔细观察console控制台的输出。当我们更改数字或颜色时我们会发现,由于CounterApp和ThemeApp是PureComponent,且都没有使用count和theme,所以它们并不会触发render,甚至Counter和Theme也没有重新render。但是,这却并不影响我们Consumer中的正常渲染。所以啊,上文提到Old Context API的子孙组件可能不更新的这个遗留问题算是真的解决了~~~

3. 说好的高阶组件呢?

通过上面“生动形象”的例子,想必大家都已经领会到New Context API的魔力,内心是不是有点蠢蠢欲动?因为有了New Context API,我们似乎不需要再借助redux也能创建一个store来管理状态了(而且还是区域级,不一定非得在整个应用的最顶层)。当然了,这里并非是说redux无用,只是提供状态管理的另一种思路。

咦~文章的标题不是高阶组件 + New Context API = ?吗,怎么跑偏了?说好的高阶组件呢?

别急,上面的只是开胃小菜,普及New Context API的基本使用方法而已。。。正菜这就来了~ 文章开头就说最近沉迷高阶组件无法自拔,所以在写完上面的demo之后就想着能不能用高阶组件再封装一层,这样使用起来可以更加顺手。你别说,还真搞出了一套。。。我们先来分析上面demo中存在的问题:

  1. 我们在通过Provider传给Consumer的value中写了两个函数onChangeCount和onChangeTheme。但是这里是不是有问题?假如这个组件足够复杂,有20个状态难道我们需要写20个函数分别一一对应更新相应的状态吗?
  2. 注意使用到Consumer的地方,我们把所有的逻辑都写在一个data => {...}函数中了。假如这里的组件很复杂怎么办?当然了,我们可以将{...}这段代码提取出来作为Counter或Theme实例的一个方法或者再封装一个组件,但是这样的代码写多了之后,就会显得重复。而且还有一个问题是,假如在Counter或Theme的其他实例方法中想获取data中的属性和update方法怎么办?

为了解决以上提出的两个问题,我要开始装逼了。。。

3.1 Provider with HOC

首先,我们先来解决第一个问题。为此,我们先新建一个ContextHOC.js文件,代码如下:

// ContextHOC.js
import React from 'react';

export const Provider = ({Provider}, store = {}) => WrappedComponent => {
  return class extends React.PureComponent {
    state = store;
    updateContext = newState => this.setState(newState);
    render() {
      return (
        <Provider value={{ ...this.state, updateContext: this.updateContext }}>
          <WrappedComponent {...this.props} />
        </Provider>
      );
    }
  };
};

由于我们的高阶组件需要包掉Provider层的逻辑,所以很显然我们返回的组件是以Provider作为顶层的一个组件,传进来的WrappedComponent会被包裹在Provider中。除此之外还可以看到,Provider会接收两个参数Provider和initialVlaue。其中,Provider就是用React.createContext创建的对象所提供的Provider方法,而store则会作为state的初始值。重点在于Provider的value属性,除了state之外,我们还传了updateContext方法。还记得问题一么?这里的updateContext正是解决这个问题的关键,因为Consumer可以通过它来更新任意的状态而不必再写一堆的onChangeXXX的方法了~

我们再来看看经过Provider with HOC改造之后,调用方应该如何使用。看代码:

// DemoContext.js
import React from 'react';
export const store = { count: 1, theme: 'red' };
export const demoContext = React.createContext();

// Demo.js
import React from 'react';

import { Provider } from './ContextHOC';
import { ThemeApp } from './ThemeApp';
import { CounterApp } from './CounterApp';
import { store, demoContext } from './DemoContext';

@Provider(demoContext, store)
class Demo extends React.PureComponent {
  render() {
    console.log('render Demo');
    return (
      <div>
        <CounterApp />
        <ThemeApp />
      </div>
    );
  }
}

咦~ 原来与Provider相关的代码在我们的Demo中全都不见了,只有一个@Provider装饰器,想要公用的状态全都写在一个store中就可以了。相比原来的Demo,现在的Demo组件只要关注自身的逻辑即可,整个组件显然看起来更加清爽了~

3.2 Consumer with HOC

接下来,我们再来解决第二个问题。在ContextHOC.js文件中,我们再导出一个Consumer函数,代码如下:

export const Consumer = ({Consumer}) => WrappedComponent => {
  return class extends React.PureComponent {
    render() {
      return (
        <Consumer>
          {data => <WrappedComponent context={data} {...this.props}/>}
        </Consumer>
      );
    }
  };
};

可以看到,上面的代码其实非常简单。。。仅仅是利用高阶组件给WrappedComponent多传了一个context属性而已,而context的值则正是Provider传过来的value。那么这样写有什么好处呢?我们来看一下调用的代码就知道了~

// CounterApp.js
import React from 'react';
import { Consumer } from './ContextHOC';
import { demoContext } from './DemoContext';

const MAP = { add: { delta: 1 }, minus: { delta: -1 } };

// ...省略CounterApp组件代码,与前面相同

@Consumer(demoContext)
class Counter extends React.PureComponent {

  onClickBtn = (type) => {
    const { count, updateContext } = this.props.context;
    updateContext({ count: count + MAP[type].delta });
  };

  render() {
    console.log('render Counter');
    return (
      <div>
        <button onClick={() => this.onClickBtn('minus')}>-</button>
        <span style={{ margin: '0 10px' }}>{this.props.context.count}</span>
        <button onClick={() => this.onClickBtn('add')}>+</button>
      </div>
    );
  }
}

// ThemeApp.js
import React from 'react';
import { Consumer } from './ContextHOC';
import { demoContext } from './DemoContext';

// ...省略ThemeApp组件代码,与前面相同

@Consumer(demoContext)
class Theme extends React.PureComponent {

  onChangeTheme = evt => {
    const newTheme = evt.target.value;
    const { theme, updateContext } = this.props.context;
    if (newTheme !== theme) {
      updateContext({ theme: newTheme });
    }
  };

  render() {
    console.log('render Theme');
    return (
      <div>
        <div style={{ width: '100px', height: '30px', margin: '0 auto', backgroundColor: this.props.context.theme }} />
        <select style={{ marginTop: '20px' }} onChange={this.onChangeTheme}>
          {['red', 'green', 'yellow', 'blue'].map(_ => (
            <option key={_}>{_}</option>
          ))}
        </select>
      </div>
    )
  }
}

可以看到,改造之后的Counter和Theme代码一定程度上实现了去Consumer化。因为和Consumer相关的逻辑仅剩一个@consumer装饰器了,而且我们只要提供和祖先组件中Provider配对的Consumer就可以了。相比最初的Counter和Theme组件,现在的组件也是更加清爽了,只需关注自身的逻辑即可。

不过需要特别注意的是,现在想要获取Provider提供的公用状态值时,改成了从this.props.context中获取;想要更新状态的时候,调用this.props.context.updateContext即可。

为什么?因为通过@consumer装饰的组件Counter和Theme现在就是ContextHOC文件中的那个WrappedComponent,我们已经把Provider传下来的Value作为context属性传给它了。所以,我们再次通过高阶组件简化了操作~

下面我们再来看看使用高阶组件改造过后的代码看看运行的效果。

3.3 优化

你以为文章到这里就要结束了吗?当然不是,写论文的套路不都还要提出个优化方法然后做实验比较么~ 更何况上面这张图有问题。。。

没错,通过ContextHOC改造过后,上面的这张运行效果图似乎看上去没有问题,但是仔细看Console控制台的输出你就会发现,当更新count或theme任意其中一个的时候,Counter和Theme都重新渲染了一次!!!可是,我的Counter和Theme组件明明都已经是PureComponent了啊~ 为什么没有用!!!

原因很简单,因为我们传给WrappedComponent的context每次都是一个新对象,所以就算你的WrappedComponent是PureComponent也无济于事。。。那么怎么办呢?其实,上文中的Consumer with HOC操作非常粗糙,我们直接把Provider提供的value值直接一股脑儿地传给了WrappedComponent,而不管WrappedComponent是否真的需要。因此,只要我们对传给WrappedComponent的属性值精细化控制,不传不相关的属性就可以了。来看看改造后的Consumer代码:

// ContextHOC.js
export const Consumer = ({Consumer}, relatedKeys = []) => WrappedComponent => {
  return class extends React.PureComponent {
    _version = 0;
    _context = {};
    getContext = data => {
      if (relatedKeys.length === 0) return data;
      [...relatedKeys, 'updateContext'].forEach(k => {
        if(this._context[k] !== data[k]) {
          this._version++;
          this._context[k] = data[k];
        }
      });
      return this._context;
    };
    render() {
      return (
        <Consumer>
          {data => {
            const newContext = this.getContext(data);
            const newProps = { context: newContext, _version: this._version, ...this.props };
            return <WrappedComponent {...newProps} />;
          }}
        </Consumer>
      );
    }
  };
};

// 别忘了给Consumer组件指定relatedKeys

// CounterApp.js
@Consumer(demoContext, ['count'])
class Counter extends React.PureComponent {
  // ...省略
}

// ThemeApp.js
@Consumer(demoContext, ['theme'])
class Theme extends React.PureComponent {
  // ...省略
}

相比于第一版的Consumer函数,现在这个似乎复杂了一点点。但是其实还是很简单,核心**刚才上面已经说了,这次我们会根据relatedKeys从Provider传下来的value中匹配出WrappedComponent真正想要的属性。而且,为了保证传给WrappedComponent的context值不再每次都是一个新对象,我们将它保存在了组件的实例上。另外,只要Provider中某个落在relatedKeys中的属性值发生变化,this._version值就会发生变化,从而也保证了WrappedComponent能够正常更新。

最后,我们再来看下经过优化后的运行效果。

4. 写在最后

经过今天这波操作,无论是对New Context API还是HOC都有了更深一步的理解和运用,所以收货还是挺大的。最重要的是,在现有项目不想引进redux和mobx的前提下,本文提出的这种方案似乎也能在一定程度上解决某些复杂组件的状态管理问题。

当然了,文中的代码还有很多不严谨的地方,还需要继续进一步地提升。完整代码在这儿,欢迎指出不对或者需要改进的地方。

Test

那么我能写么?

一次react-router + react-transition-group实现转场动画的探索

1. Introduction

在日常开发中,页面切换时的转场动画是比较基础的一个场景。在react项目当中,我们一般都会选用react-router来管理路由,但是react-router却并没有提供相应的转场动画功能,而是非常生硬的直接替换掉组件。一定程度上来说,体验并不是那么友好。

为了在react中实现动画效果,其实我们有很多的选择,比如:react-transition-groupreact-motionAnimated等等。但是,由于react-transition-group给元素添加的enter,enter-active,exit,exit-active这一系列勾子,简直就是为我们的页面入场离场而设计的。基于此,本文选择react-transition-group来实现动画效果。

接下来,本文就将结合两者提供一个实现路由转场动画的思路,权当抛砖引玉~

2. Requirements

我们先明确要完成的转场动画是什么效果。如下图所示:

3. react-router

首先,我们先简要介绍下react-router的基本用法(详细看官网介绍)。

这里我们会用到react-router提供的BrowserRouterSwitchRoute三个组件。

  • BrowserRouter:以html5提供的history api形式实现的路由(还有一种hash形式实现的路由)。
  • Switch:多个Route组件同时匹配时,默认都会显示,但是被Switch包裹起来的Route组件只会显示第一个被匹配上的路由。
  • Route:路由组件,path指定匹配的路由,component指定路由匹配时展示的组件。
// src/App1/index.js
export default class App1 extends React.PureComponent {
  render() {
    return (
      <BrowserRouter>
        <Switch>
          <Route exact path={'/'} component={HomePage}/>
          <Route exact path={'/about'} component={AboutPage}/>
          <Route exact path={'/list'} component={ListPage}/>
          <Route exact path={'/detail'} component={DetailPage}/>
        </Switch>
      </BrowserRouter>
    );
  }
}

如上所示,这是路由关键的实现部分。我们一共创建了首页关于页列表页详情页这四个页面。跳转关系为:

  1. 首页 ↔ 关于页
  2. 首页 ↔ 列表页 ↔ 详情页

来看下目前默认的路由切换效果:

4. react-transition-group

从上面的效果图中,我们可以看到react-router在路由切换时完全没有过渡效果,而是直接替换的,显得非常生硬。

正所谓工欲善其事,必先利其器,在介绍实现转场动画之前,我们得先学习如何使用react-transition-group。基于此,接下来就将对其提供的CSSTransition和TransitionGroup这两个组件展开简要介绍。

4.1 CSSTransition

CSSTransition是react-transition-group提供的一个组件,这里简单介绍下其工作原理。

When the in prop is set to true, the child component will first receive the class example-enter, then the example-enter-active will be added in the next tick. CSSTransition forces a reflow between before adding the example-enter-active. This is an important trick because it allows us to transition between example-enter and example-enter-active even though they were added immediately one after another. Most notably, this is what makes it possible for us to animate appearance.

这是来自官网上的一段描述,意思是当CSSTransition的in属性置为true时,CSSTransition首先会给其子组件加上xxx-enter的class,然后在下个tick时马上加上xxx-enter-active的class。所以我们可以利用这一点,通过css的transition属性,让元素在两个状态之间平滑过渡,从而得到相应的动画效果。

相反地,当in属性置为false时,CSSTransition会给子组件加上xxx-exit和xxx-exit-active的class。(更多详细介绍可以戳官网查看)

基于以上两点,我们是不是只要事先写好class对应的css样式即可?可以做个小demo试试,如下代码所示:

// src/App2/index.js
export default class App2 extends React.PureComponent {

  state = {show: true};

  onToggle = () => this.setState({show: !this.state.show});

  render() {
    const {show} = this.state;
    return (
      <div className={'container'}>
        <div className={'square-wrapper'}>
          <CSSTransition
            in={show}
            timeout={500}
            classNames={'fade'}
            unmountOnExit={true}
          >
            <div className={'square'} />
          </CSSTransition>
        </div>
        <Button onClick={this.onToggle}>toggle</Button>
      </div>
    );
  }
}
/* src/App2/index.css */
.fade-enter {
  opacity: 0;
  transform: translateX(100%);
}

.fade-enter-active {
  opacity: 1;
  transform: translateX(0);
  transition: all 500ms;
}

.fade-exit {
  opacity: 1;
  transform: translateX(0);
}

.fade-exit-active {
  opacity: 0;
  transform: translateX(-100%);
  transition: all 500ms;
}

来看看效果,是不是和页面的入场离场效果有点相似?

4.2 TransitionGroup

用CSSTransition来处理动画固然很方便,但是直接用来管理多个页面的动画还是略显单薄。为此我们再来介绍react-transition-group提供的TransitionGroup这个组件。

The component manages a set of transition components ( and ) in a list. Like with the transition components, is a state machine for managing the mounting and unmounting of components over time.

如官网介绍,TransitionGroup组件就是用来管理一堆节点mounting和unmounting过程的组件,非常适合处理我们这里多个页面的情况。这么介绍似乎有点难懂,那就让我们来看段代码,解释下TransitionGroup的工作原理。

// src/App3/index.js
export default class App3 extends React.PureComponent {

  state = {num: 0};

  onToggle = () => this.setState({num: (this.state.num + 1) % 2});

  render() {
    const {num} = this.state;
    return (
      <div className={'container'}>
        <TransitionGroup className={'square-wrapper'}>
          <CSSTransition
            key={num}
            timeout={500}
            classNames={'fade'}
          >
            <div className={'square'}>{num}</div>
          </CSSTransition>
        </TransitionGroup>
        <Button onClick={this.onToggle}>toggle</Button>
      </div>
    );
  }
}

我们先来看效果,然后再做解释:

对比App3和App2的代码,我们可以发现这次CSSTransition没有in属性了,而是用到了key属性。但是为什么仍然可以正常工作呢?

在回答这个问题之前,我们先来思考一个问题:

由于react的dom diff机制用到了key属性,如果前后两次key不同,react会卸载旧节点,挂载新节点。那么在上面的代码中,由于key变了,旧节点难道不是应该立马消失,但是为什么我们还能看到它淡出的动画过程呢?

关键就出在TransitionGroup身上,因为它在感知到其children变化时,会先保存住即将要被移除的节点,而在其动画结束时才会真正移除该节点。

所以在上面的例子中,当我们按下toggle按钮时,变化的过程可以这样理解:

<TransitionGroup>
  <div>0</div>
</TransitionGroup>

⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️

<TransitionGroup>
  <div>0</div>
  <div>1</div>
</TransitionGroup>

⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️

<TransitionGroup>
  <div>1</div>
</TransitionGroup>

如上所解释,我们完全可以巧妙地借用key值的变化来让TransitionGroup来接管我们在过渡时的页面创建和销毁工作,而仅仅需要关注如何选择合适的key值和需要什么样css样式来实现动画效果就可以了。

5. Page transition animation

基于前文对react-router和react-transition-group的介绍,我们已经掌握了基础,接下来就可以将两者结合起来做页面切换的转场动画了。

在上一小节的末尾有提到,用了TransitionGroup之后我们的问题变成如何选择合适的key值。那么在路由系统中,什么作为key值比较合适呢?

既然我们是在页面切换的时候触发转场动画,自然是跟路由相关的值作为key值合适了。而react-router中的location对象就有一个key属性,它会随着浏览器中的地址发生变化而变化。然而,在实际场景中似乎并不适合,因为query参数或者hash变化也会导致location.key发生变化,但往往这些场景下并不需要触发转场动画。

因此,个人觉得key值的选取还是得根据不同的项目而视。大部分情况下,还是推荐用location.pathname作为key值比较合适,因为它恰是我们不同页面的路由。

说了这么多,还是看看具体的代码是如何将react-transition-group应用到react-router上的吧:

// src/App4/index.js
const Routes = withRouter(({location}) => (
  <TransitionGroup className={'router-wrapper'}>
    <CSSTransition
      timeout={5000}
      classNames={'fade'}
      key={location.pathname}
    >
      <Switch location={location}>
        <Route exact path={'/'} component={HomePage} />
        <Route exact path={'/about'} component={AboutPage} />
        <Route exact path={'/list'} component={ListPage} />
        <Route exact path={'/detail'} component={DetailPage} />
      </Switch>
    </CSSTransition>
  </TransitionGroup>
));

export default class App4 extends React.PureComponent {
  render() {
    return (
      <BrowserRouter>
        <Routes/>
      </BrowserRouter>
    );
  }
}

这是效果:

App4的代码思路跟App3大致相同,只是将原来的div换成了Switch组件,而且还用到了withRouter。

withRouter是react-router提供的一个高阶组件,可以为你的组件提供location,history等对象。因为我们这里要用location.pathname作为CSSTransition的key值,所以用到了它。

另外,这里有一个坑,就是Switch的location属性。

A location object to be used for matching children elements instead of the current history location (usually the current browser URL).

这是官网中的描述,意思就是Switch组件会用这个对象来匹配其children中的路由,而且默认用的就是当前浏览器的url。如果在上面的例子中我们不给它指定,那么在转场动画中会发生很奇怪的现象,就是同时有两个相同的节点在移动。。。就像下面这样:

这是因为TransitionGroup组件虽然会保留即将被remove的Switch节点,但是当location变化时,旧的Switch节点会用变化后的location去匹配其children中的路由。由于location都是最新的,所以两个Switch匹配出来的页面是相同的。好在我们可以改变Switch的location属性,如上述代码所示,这样它就不会总是用当前的location匹配了。

6. Page dynamic transition animation

虽然前文用react-transition-group和react-router实现了一个简单的转场动画,但是却存在一个严重的问题。仔细观察上一小节的示意图,不难发现我们的进入下个页面的动画效果是符合预期的,但是后退的动画效果是什么鬼。。。明明应该是上个页面从左侧淡入,当前页面从右侧淡出。但是为什么却变成当前页面从左侧淡出,下个页面从右侧淡入,跟进入下个页面的效果是一样的。其实错误的原因很简单:

首先,我们把路由改变分成forward和back两种操作。在forward操作时,当前页面的exit效果是向左淡出;在back操作时,当前页面的exit效果是向右淡出。所以我们只用fade-exit和fade-exit-active这两个class,很显然,得到的动画效果肯定是一致的。

因此,解决方案也很简单,我们用两套class来分别管理forward和back操作时的动画效果就可以了。

/* src/App5/index.css */

/* 路由前进时的入场/离场动画 */
.forward-enter {
  opacity: 0;
  transform: translateX(100%);
}

.forward-enter-active {
  opacity: 1;
  transform: translateX(0);
  transition: all 500ms;
}

.forward-exit {
  opacity: 1;
  transform: translateX(0);
}

.forward-exit-active {
  opacity: 0;
  transform: translateX(-100%);
  transition: all 500ms;
}

/* 路由后退时的入场/离场动画 */
.back-enter {
  opacity: 0;
  transform: translateX(-100%);
}

.back-enter-active {
  opacity: 1;
  transform: translateX(0);
  transition: all 500ms;
}

.back-exit {
  opacity: 1;
  transform: translateX(0);
}

.back-exit-active {
  opacity: 0;
  transform: translate(100%);
  transition: all 500ms;
}

不过光有css的支持还不行,我们还得在不同的路由操作时加上合适的class才行。那么问题又来了,在TransitionGroup的管理下,一旦某个组件挂载后,其exit动画其实就已经确定了,可以看官网上的这个issue。也就是说,就算我们动态地给CSSTransition添加不同的ClassNames属性来指定动画效果,但其实是无效的。

解决方案其实在那个issue的下面就给出了,我们可以借助TransitionGroup的ChildFactory属性以及React.cloneElement方法来强行覆盖其className。比如:

<TransitionGroup childFactory={child => React.cloneElement(child, {
  classNames: 'your-animation-class-name'
})}>
  <CSSTransition>
    ...
  </CSSTransition>
</TransitionGroup>

上述几个问题都解决之后,剩下的问题就是如何选择合适的动画class了。而这个问题的实质在于如何判断当前路由的改变是forward还是back操作了。好在react-router已经贴心地给我们准备好了,其提供的history对象有一个action属性,代表当前路由改变的类型,其值是'PUSH' | 'POP' | 'REPLACE'。所以,我们再调整下代码:

// src/App5/index.js
const ANIMATION_MAP = {
  PUSH: 'forward',
  POP: 'back'
}

const Routes = withRouter(({location, history}) => (
  <TransitionGroup
    className={'router-wrapper'}
    childFactory={child => React.cloneElement(
      child,
      {classNames: ANIMATION_MAP[history.action]}
    )}
  >
    <CSSTransition
      timeout={500}
      key={location.pathname}
    >
      <Switch location={location}>
        <Route exact path={'/'} component={HomePage} />
        <Route exact path={'/about'} component={AboutPage} />
        <Route exact path={'/list'} component={ListPage} />
        <Route exact path={'/detail'} component={DetailPage} />
      </Switch>
    </CSSTransition>
  </TransitionGroup>
));

再来看下修改之后的动画效果:

7. Optimize

其实,本节的内容算不上优化,转场动画的思路到这里基本上已经结束了,你可以脑洞大开,通过添加css来实现更炫酷的转场动画。不过,这里还是想再讲下如何将我们的路由写得更配置化(个人喜好,不喜勿喷)。

我们知道,react-router在升级v4的时候,做了一次大改版。更加推崇动态路由,而非静态路由。不过具体问题具体分析,在一些项目中个人还是喜欢将路由集中化管理,就上面的例子而言希望能有一个RouteConfig,就像下面这样:

// src/App6/RouteConfig.js
export const RouterConfig = [
  {
    path: '/',
    component: HomePage
  },
  {
    path: '/about',
    component: AboutPage,
    sceneConfig: {
      enter: 'from-bottom',
      exit: 'to-bottom'
    }
  },
  {
    path: '/list',
    component: ListPage,
    sceneConfig: {
      enter: 'from-right',
      exit: 'to-right'
    }
  },
  {
    path: '/detail',
    component: DetailPage,
    sceneConfig: {
      enter: 'from-right',
      exit: 'to-right'
    }
  }
];

透过上面的RouterConfig,我们可以清晰的知道每个页面所对应的组件是哪个,而且还可以知道其转场动画效果是什么,比如关于页面是从底部进入页面的,列表页详情页都是从右侧进入页面的。总而言之,我们通过这个静态路由配置表可以直接获取到很多有用的信息,而不需要深入到代码中去获取信息。

那么,对于上面的这个需求,我们对应的路由代码需要如何调整呢?请看下面:

// src/App6/index.js
const DEFAULT_SCENE_CONFIG = {
  enter: 'from-right',
  exit: 'to-exit'
};

const getSceneConfig = location => {
  const matchedRoute = RouterConfig.find(config => new RegExp(`^${config.path}$`).test(location.pathname));
  return (matchedRoute && matchedRoute.sceneConfig) || DEFAULT_SCENE_CONFIG;
};

let oldLocation = null;
const Routes = withRouter(({location, history}) => {

  // 转场动画应该都是采用当前页面的sceneConfig,所以:
  // push操作时,用新location匹配的路由sceneConfig
  // pop操作时,用旧location匹配的路由sceneConfig
  let classNames = '';
  if(history.action === 'PUSH') {
    classNames = 'forward-' + getSceneConfig(location).enter;
  } else if(history.action === 'POP' && oldLocation) {
    classNames = 'back-' + getSceneConfig(oldLocation).exit;
  }

  // 更新旧location
  oldLocation = location;

  return (
    <TransitionGroup
      className={'router-wrapper'}
      childFactory={child => React.cloneElement(child, {classNames})}
    >
      <CSSTransition timeout={500} key={location.pathname}>
        <Switch location={location}>
          {RouterConfig.map((config, index) => (
            <Route exact key={index} {...config}/>
          ))}
        </Switch>
      </CSSTransition>
    </TransitionGroup>
  );
});

由于css代码有点多,这里就不贴了,不过无非就是相应的转场动画配置,完整的代码可以看github上的仓库。我们来看下目前的效果:

8. Summarize

本文先简单介绍了react-router和react-transition-group的基本使用方法;其中还分析了利用CSSTransition和TransitionGroup制作动画的工作原理;接着又将react-router和react-transition-group两者结合在一起完成一次转场动画的尝试;并利用TransitionGroup的childFactory属性解决了动态转场动画的问题;最后将路由配置化,实现路由的统一管理以及动画的配置化,完成一次react-router + react-transition-group实现转场动画的探索。

9. Reference

  1. A shallow dive into router v4 animated transitions
  2. Dynamic transitions with react router and react transition group
  3. Issue#182 of react-transition-group
  4. StackOverflow: react-transition-group and react clone element do not send updated props

在Flutter中创建有意思的滚动效果 - Sliver系列

1. 前言

Flutter作为时下最流行的技术之一,凭借其出色的性能以及抹平多端的差异优势,早已引起大批技术爱好者的关注,甚至一些闲鱼美团腾讯等大公司均已投入生产使用。虽然目前其生态还没有完全成熟,但身靠背后的Google加持,其发展速度已经足够惊人,可以预见将来对Flutter开发人员的需求也会随之增长。

无论是为了技术尝鲜还是以后可能的工作机会,都9102年了,作为一个前端开发者,似乎没有理由不去尝试它。正是带着这样的心理,笔者也开始学习Flutter,同时建了一个用于练习的仓库,后续所有代码都会托管在上面,欢迎star,一起学习。

在之前的文章中,我们学习了如何使用ListViewGridView这两个滚动类型组件。今天,我们就来学习另一个滚动组件CustomScrollView及其搭配使用的Sliver系列组件。掌握了它们,你就可以做一些有趣的滚动效果啦~

2. 必备知识

在进入今天的正题之前,我们先来简单了解下今天的两个主角CustomScrollViewSliverCustomScrollViewFlutter提供的可以用来自定义滚动效果的组件,它可以像胶水一样将多个Sliver粘合在一起。

什么意思呢?举个栗子(你也可以点击这里youtube上的一个视频):

假如页面中同时存在一个List和一个Grid,虽然它们看起来是一个整体,但是由于各自的滚动效果是分离的,所以没法保证一致的滚动效果。

而使用CustomScrollView组件作为滚动容器,SliverListSliverGrid分别替代ListGrid作为CustomScrollView的子组件,滚动效果再由CustomScrollView统一控制,这样就可以了。

其中SliverListSliverGrid就是我们前面提到的Sliver系列中的两员,除此之外,Sliver家族还有常用的几个:

  • SliverAppBar:Creates a material design app bar that can be placed in a CustomScrollView.
  • SliverPersistentHeader:Creates a sliver that varies its size when it is scrolled to the start of a viewport.
  • SliverFillRemaining:Creates a sliver that fills the remaining space in the viewport.
  • SliverToBoxAdapter:Creates a sliver that contains a single box widget.
  • SliverPadding:Creates a sliver that applies padding on each side of another sliver.

注意:由于CustomeScrollView的子组件只能是Sliver系列,所以如果你想将一个普通组件塞进CustomScrollView,那么务必将该组件用SliverToBoxAdapter包裹。

3. 热身:SliverList / SliverGrid

前面讲了那么多的概念似乎有些枯燥,接下来就让我们从最简单的一个例子入手来看看如何使用CustomScrollViewSliverList/SliverGrid

其实CustomScrollView的用法很简单,它有一个slivers属性,是一个Widget数组,将子组件都放在里面就可以了,其他的一些滚动相关的属性基本和我们之前学到的ListView差不多。

CustomScrollView(
  slivers: <Widget>[
    renderSliverA(),
    renderSliverB(),
    renderSliverC(),
  ],
)

再来看看SliverList,它只有一个delegate属性,可以用SliverChildListDelegateSliverChildBuilderDelegate这两个类实现。前者将会一次性全部渲染子组件,后者将会根据视窗渲染当前出现的元素,其效果可以和ListViewListView.build这两个构造函数类比。

SliverList(
  delegate: SliverChildListDelegate(
    <Widget>[
      renderA(),
      renderB(),
      renderC(),
    ]
  )
)

SliverList(
  delegate: SliverChildBuilderDelegate(
    (context, index) => renderItem(context, index),
    childCount: 10,
  )
)

通过上面的例子我们发现SliverList的使用方式和ListView大同小异,而SliverGrid也是如此,这里就不再过多赘述,来看个两列网格的例子:

SliverGrid.count(
  crossAxisCount: 2,
  children: <Widget>[
    renderA(),
    renderB(),
    renderC(),
    renderD()
  ]
)

接下来,就让我们通过一个实际例子将上面的三点结合在一起。

代码(完整版看这里

final List<Color> colorList = [
  Colors.red,
  Colors.orange,
  Colors.green,
  Colors.purple,
  Colors.blue,
  Colors.yellow,
  Colors.pink,
  Colors.teal,
  Colors.deepPurpleAccent
];

// Text组件需要用SliverToBoxAdapter包裹,才能作为CustomScrollView的子组件
Widget renderTitle(String title) {
  return SliverToBoxAdapter(
    child: Padding(
      padding: EdgeInsets.symmetric(vertical: 16),
      child: Text(
        title,
        textAlign: TextAlign.center,
        style: TextStyle(fontSize: 20),
      ),
    ),
  );
}

CustomScrollView(
  slivers: <Widget>[
    renderTitle('SliverGrid'),
    SliverGrid.count(
      crossAxisCount: 3,
      children: colorList.map((color) => Container(color: color)).toList(),
    ),
    renderTitle('SliverList'),
    SliverFixedExtentList(		// SliverList的语法糖,用于每个item固定高度的List
      delegate: SliverChildBuilderDelegate(
        (context, index) => Container(color: colorList[index]),
        childCount: colorList.length,
      ),
      itemExtent: 100,
    ),
  ],
)

效果图

上面的例子中还有一点需要注意的是:我们将标题组件放在了SliverToBoxAdapter内,因为CustomScrollView只接受Sliver系列的组件。

4. 眼前一亮的SliverAppBar

AppBar是常用来构建一个页面头部Bar的组件,在CustomScrollView中与其对应的是SliverAppBar组件。它有什么神奇之处呢?随着页面的滚动,头部Bar将会有一个收起过渡的效果。我们先来看下效果:

float效果 snap效果 pinned效果

通过上面的预览图,想必你肯定很好奇SliverAppBar中的过渡效果是如何实现的~先别急,我们先来看下应该如何使用它:

SliverAppBar(
  floating: true,
  snap: true,
  pinned: true,
  expandedHeight: 250,
  flexibleSpace: FlexibleSpaceBar(
    title: Text(this.title),
    background: Image.network(
      'http://img1.mukewang.com/5c18cf540001ac8206000338.jpg',
      fit: BoxFit.cover,
    ),
  ),
)

SliverAppBar最重要的几个属性在上面的例子中罗列出来。其中:

  • expandedHeight:展开状态下appBar的高度,即图中图片所占空间;
  • flexibleSpace:空间大小可变的组件,Flutter给我们提供了一个现成的FlexibleSpaceBar组件,给我们处理好了title过渡的效果。

另外,floating/snap/pinned这三个属性可以指定SliverAppBar内容滑出屏幕之后的表现形式。

  • float:向下滑动时,即使当前CustomScrollView不在顶部,SliverAppBar也会跟着一起向下出现;
  • snap:当手指放开时,SliverAppBar会根据当前的位置进行调整,始终保持展开收起的状态;
  • pinned:不同于float效果,当SliverAppBar内容滑出屏幕时,将始终渲染一个固定在顶部的收起状态组件。

需要注意的是:snap效果一定要在floattrue时才会生效。另外,你也可以将这三者进行组合使用。

5. 花样多变的SliverPersistentHeader

在上一小节中我们见识到了SliverAppBar的神奇之处,其实它就是基于SliverPersistentHeader实现的。通过SliverPersistentHeader,我们还可以实现sticky吸顶的效果。

SliverPersistentHeader最重要的一个属性是SliverPersistentHeaderDelegate,为此我们需要实现一个类继承自SliverPersistentHeaderDelegate

class StickyTabBarDelegate extends SliverPersistentHeaderDelegate {

  @override
  double get minExtent => null;

  @override
  double get maxExtent => null;

  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => null;
  
  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => null;
}

可以看到,SliverPersistentHeaderDelegate的实现类必须实现其4个方法。其中:

  • minExtent:收起状态下组件的高度;
  • maxExtent:展开状态下组件的高度;
  • shouldRebuild:类似于react中的shouldComponentUpdate
  • build:构建渲染的内容。

接下来,我们就来实现一个TabBar吸顶的效果。

代码(完整版看这里

CustomScrollView(
  slivers: <Widget>[
    SliverAppBar(
      // ...
    ),
    SliverPersistentHeader(	// 可以吸顶的TabBar
      pinned: true,
      delegate: StickyTabBarDelegate(
        child: TabBar(
          labelColor: Colors.black,
          controller: this.tabController,
          tabs: <Widget>[
            Tab(text: 'Home'),
            Tab(text: 'Profile'),
          ],
        ),
      ),
    ),
    SliverFillRemaining(		// 剩余补充内容TabBarView
      child: TabBarView(
        controller: this.tabController,
        children: <Widget>[
          Center(child: Text('Content of Home')),
          Center(child: Text('Content of Profile')),
        ],
      ),
    ),
  ],
)

class StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
  final TabBar child;

  StickyTabBarDelegate({@required this.child});

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return this.child;
  }

  @override
  double get maxExtent => this.child.preferredSize.height;

  @override
  double get minExtent => this.child.preferredSize.height;

  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
    return true;
  }
}

效果图

根据上面的图我们可以看到,当下方tab内容滑出屏幕后,tabBar并没有跟着一起滑走,而是粘在了顶部。可见SliverPersistentHeader的确可以满足我们的sticky效果。

不过SliverPersistentHeader的神奇可远不止如此哦~我们可以通过它自定义一些头部的过渡效果,毕竟SliverAppBar也是通过它实现的。就比如下方这个电影详情页的头部过渡效果,这在一般的app种还是比较常见的。

那么这种效果要如何实现呢?关键就在于build方法中的shrinkOffset属性,它代表当前头部的滚动偏移量。我们可以根据它计算得到当前收起头部的背景颜色以及图标和文案的字体颜色,这样就能根据当前位置得到过渡效果啦~

代码(完整版看这里

class SliverCustomHeaderDelegate extends SliverPersistentHeaderDelegate {
  final double collapsedHeight;
  final double expandedHeight;
  final double paddingTop;
  final String coverImgUrl;
  final String title;

  SliverCustomHeaderDelegate({
    this.collapsedHeight,
    this.expandedHeight,
    this.paddingTop,
    this.coverImgUrl,
    this.title,
  });

  @override
  double get minExtent => this.collapsedHeight + this.paddingTop;

  @override
  double get maxExtent => this.expandedHeight;

  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
    return true;
  }

  Color makeStickyHeaderBgColor(shrinkOffset) {
    final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255).clamp(0, 255).toInt();
    return Color.fromARGB(alpha, 255, 255, 255);
  }

  Color makeStickyHeaderTextColor(shrinkOffset, isIcon) {
    if(shrinkOffset <= 50) {
      return isIcon ? Colors.white : Colors.transparent;
    } else {
      final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255).clamp(0, 255).toInt();
      return Color.fromARGB(alpha, 0, 0, 0);
    }
  }

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return Container(
      height: this.maxExtent,
      width: MediaQuery.of(context).size.width,
      child: Stack(
        fit: StackFit.expand,
        children: <Widget>[
          // 背景图
          Container(child: Image.network(this.coverImgUrl, fit: BoxFit.cover)),
          // 收起头部
          Positioned(
            left: 0,
            right: 0,
            top: 0,
            child: Container(
              color: this.makeStickyHeaderBgColor(shrinkOffset),	// 背景颜色
              child: SafeArea(
                bottom: false,
                child: Container(
                  height: this.collapsedHeight,
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: <Widget>[
                      IconButton(
                        icon: Icon(
                          Icons.arrow_back_ios,
                          color: this.makeStickyHeaderTextColor(shrinkOffset, true),	// 返回图标颜色
                        ),
                        onPressed: () => Navigator.pop(context),
                      ),
                      Text(
                        this.title,
                        style: TextStyle(
                          fontSize: 20,
                          fontWeight: FontWeight.w500,
                          color: this.makeStickyHeaderTextColor(shrinkOffset, false),	// 标题颜色
                        ),
                      ),
                      IconButton(
                        icon: Icon(
                          Icons.share,
                          color: this.makeStickyHeaderTextColor(shrinkOffset, true),	// 分享图标颜色
                        ),
                        onPressed: () {},
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

上面的代码虽然很长,但大部分是构建widget的代码。所以,我们重点关注makeStickyHeaderTextColormakeStickyHeaderBgColor即可。这两个方法都是根据当前的shrinkOffset值计算过渡过程中的颜色值。另外,这里需要注意头部在iPhoneX及以上的刘海头涉及,可以用SafeArea组件解决问题。

6. 总结

本文首先介绍了CustomScrollViewSliver系列组件的概念及其关系,接着以SliverListSliverGrid结合的示例说明了其使用方法。然后,又介绍了较常用的SliverAppBar组件,分别解释了其float/snap/pinned各自的效果。最后,讲解了SliverPersistentHeader组件的使用方法,并用实际例子加以说明其自定义过渡效果的用法。希望通过本文的介绍,你可以用CustomScrollViewSliver系列组件创建出更有意思的滚动效果~

本文所有代码托管在这儿,也可以关注我的Blog,欢迎一起交流学习~

Flutter网格型布局 - GridView篇

1. 前言

Flutter作为时下最流行的技术之一,凭借其出色的性能以及抹平多端的差异优势,早已引起大批技术爱好者的关注,甚至一些闲鱼美团腾讯等大公司均已投入生产使用。虽然目前其生态还没有完全成熟,但身靠背后的Google加持,其发展速度已经足够惊人,可以预见将来对Flutter开发人员的需求也会随之增长。

无论是为了现在的技术尝鲜还是将来的潮流趋势,都9102年了,作为一个前端开发者,似乎没有理由不去尝试它。正是带着这样的心理,笔者也开始学习Flutter,同时建了一个用于练习的仓库,后续所有代码都会托管在上面,欢迎star,一起学习。

经过上一篇ListView组件的学习,我们已经对滚动型组件的使用有了初步认识,这对今天要学习的GridView组件十分有帮助。因为两者都继承自BoxScrollView,所以两者的属性有80%以上是相同的,用法非常相似。

而且如下图所示可见,GridView网格布局在app中的使用频率其实非常高,所以接下来就让我们来看看在Flutter中如何使用吧~

app中网格布局的使用

2. 初识GridView

今天我们的主角GridView一共有5个构造函数:GridViewGridView.builderGridView.countGridView.extentGridView.custom。但是不用慌,因为可以说其实掌握其默认构造函数就都会了~

来看下GridView构造函数(已省略不常用属性):

GridView({
  Key key,
  Axis scrollDirection = Axis.vertical,
  bool reverse = false,
  ScrollController controller,
  ScrollPhysics physics,
  bool shrinkWrap = false,
  EdgeInsetsGeometry padding,
  @required this.gridDelegate,
  double cacheExtent,
  List<Widget> children = const <Widget>[],
})

虽然又是一大堆属性,但是大部分都很熟悉,老朋友嘛~除了一个必填参数gridDelegate外,全和ListView默认构造函数的参数一样,这也是文章开头为什么说掌握了ListView再学GridView非常容易的原因。

那么接下来,就让我们来重点关注下gridDelegate这个参数,它其实是GridView组件如何控制排列子元素的一个委托。跟踪源码我们可以在scroll_view.dart中看到,gridDelegate的类型是SliverGridDelegate,进一步跟踪进sliver_grid.dart可以看到SliverGridDelegate其实是一个抽象类,而且一共有两个实现类:

  • SliverGridDelegateWithFixedCrossAxisCount:用于固定列数的场景;
  • SliverGridDelegateWithMaxCrossAxisExtent:用于子元素有最大宽度限制的场景;

2.1 SliverGridDelegateWithFixedCrossAxisCount

我们先来看下SliverGridDelegateWithFixedCrossAxisCount,根据类名我们也能大概猜它是干什么用的:如果你的布局中每一行的列数是固定的,那你就应该用它。

来看下其构造函数:

SliverGridDelegateWithFixedCrossAxisCount({
  @required this.crossAxisCount,
  this.mainAxisSpacing = 0.0,
  this.crossAxisSpacing = 0.0,
  this.childAspectRatio = 1.0,
})
  • crossAxisCount:列数,即一行有几个子元素;
  • mainAxisSpacing:主轴方向上的空隙间距;
  • crossAxisSpacing:次轴方向上的空隙间距;
  • childAspectRatio:子元素的宽高比例。

属性解释

想必看到上面的示例图,你就秒懂其中各个参数的含义了。不过,这里有一点需要特别注意:如果你的子元素宽高比例不为1,那么你一定要设置childAspectRatio属性

2.2 SliverGridDelegateWithMaxCrossAxisExtent

SliverGridDelegateWithMaxCrossAxisExtent在实际应用中可能会比较少,来看下其构造函数:

SliverGridDelegateWithMaxCrossAxisExtent({
  @required this.maxCrossAxisExtent,
  this.mainAxisSpacing = 0.0,
  this.crossAxisSpacing = 0.0,
  this.childAspectRatio = 1.0,
})

可以看到除了maxCrossAxisExtent外,其他参数和SliverGridDelegateWithFixedCrossAxisCount都是一样的。那么maxCrossAxisExtent是干什么的呢?我们来看个例子:

假如手机屏宽375crossAxisSpacing值为0

  • maxCrossAxisExtent值为125时,网格列数将是3。因为125 * 3 = 375,刚好,每一列的宽度就是375/3
  • maxCrossAxisExtent值为126时,网格列数将是3。因为126 * 3 > 375,显示不下,每一列的宽度将是375/3
  • maxCrossAxisExtent值为124时,网格列数将是4。因为124 * 3 < 375,仍有多余,每一列的宽度将是375/4

可以看到,maxCrossAxisExtent其实就是告诉GridView组件子元素的最大宽度可能是多少,然后计算得到合适的列宽(实际上,我们也很少这么应用,所以这种方法的使用频率不高)。

3. 实际应用

经过前面的介绍,我们已经对GrdiView组件有了初步了解,下面就来看看如何使用。还记得之前GridView的各种构造函数吗?其实:

  1. GridView默认构造函数可以类比于ListView默认构造函数,适用于有限个数子元素的场景,因为GridView组件会一次性全部渲染children中的子元素组件;
  2. GridView.builder构造函数可以类比于ListView.builder构造函数,适用于长列表的场景,因为GridView组件会根据子元素是否出现在屏幕内而动态创建销毁,减少内存消耗,更高效渲染;
  3. GridView.count构造函数是GrdiView使用SliverGridDelegateWithFixedCrossAxisCount的简写(语法糖),效果完全一致;
  4. GridView.extent构造函数式GridView使用SliverGridDelegateWithMaxCrossAxisExtent的简写(语法糖),效果完全一致。

先来看一个简单的例子,它使用到GridView.count构造函数模仿美团外卖首页服务列表(服务菜单项的代码可以看这里,也算是对基础组件使用的进一步巩固):

代码(文件地址

GridView.count(
  crossAxisCount: 5,
  padding: EdgeInsets.symmetric(vertical: 0),
  children: serviceList.map((item) => ServiceItem(data: item)).toList(),
)

/*************/
/* 完全等同于 */
/************/

GridView(
  padding: EdgeInsets.symmetric(vertical: 0),
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 5,
  ),
  children: serviceList.map((item) => ServiceItem(data: item)).toList(),
)

预览

再来看一个模仿喜马拉雅中相声列表用到GridView.builder创建网格布局的具体例子(相声卡片的代码可以看这里):

代码(文件地址

GridView.builder(
  shrinkWrap: true,
  itemCount: programmeList.length,
  physics: NeverScrollableScrollPhysics(),
  padding: EdgeInsets.symmetric(horizontal: 16),
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3,
    mainAxisSpacing: 10,
    crossAxisSpacing: 10,
    childAspectRatio: 0.7,
  ),
  itemBuilder: (context, index) {
    return Programme(data: programmeList[index]);
  },
)

预览

4. 总结

本文先是介绍了GridView组件的属性含义,并着重讲解了SliverGridDelegateWithFixedCrossAxisCountSliverGridDelegateWithMaxCrossAxisExtent分别适用的应用场景。然后,通过两个实际的应用例子介绍了GridView组件常用的构造函数使用方法。希望通过本文的介绍,你可以掌握Flutter中网格型布局的使用~

本文所有代码托管在这儿,欢迎一起交流学习~

RN自定义组件封装 - 播放类似PPT动画

1. 前言

近日,被安排做一个开场动画的任务。虽然RN提供了Animated来自定义动画,但是本次动画中的元素颇多,交互甚烦。。。在完成任务的同时,发现很多步骤其实是重复的,于是封装了一个小组件记录一下,分享给大家。

2. 初步尝试

分析一下:虽然这次的动画需求步骤挺多的,但是把每一步动画拆解成step1, step2, step3, step4... 讲道理应该还是能够实现的吧?嗯,用Animated.Value()创建值,然后再配上Animated.timing应该就好了。

想到这,反手就是创建一个demo.js,先做个往上飘的气球试试先吧。

export class Demo1 extends PureComponent {

  constructor(props) {
    super(props);
  }

  componentWillMount() {
    this._initAnimation();
  }

  componentDidMount() {
    this._playAnimation();
  }

  _initAnimation() {
    this.topAnimatedValue = new Animated.Value(400);
    this.balloonStyle = {
      position: 'absolute',
      left: 137.5,
      top: this.topAnimatedValue.interpolate({
        inputRange: [-999999, 999999],
        outputRange: [-999999, 999999]
      })
    };
  }

  _playAnimation() {
    Animated.timing(this.topAnimatedValue, {
      toValue: 200,
      duration: 1500
    }).start();
  }

  render() {
    return (
      <View style={styles.demoContainer}>
        <Animated.Image
          style={[styles.balloonImage, this.balloonStyle]}
          source={require('../../pic/demo1/balloon.png')}
          />
      </View>
    );
  }
}

当然,这是再简单不过的基础动画了。。。如果我们让这里的气球一开始最好先是从底部的一个点放大,并且有一个渐入的效果,完了之后再往上飘,这该怎么实现呢?于是代码变成了这样:

export class Demo1 extends PureComponent {

  ...

  _interpolateAnimation(animatedValue, inputRange, outputRange) {
    return animatedValue.interpolate({inputRange, outputRange});
  }

  _initAnimation() {

    this.opacityAnimatedValue = new Animated.Value(0);
    this.scaleAnimatedValue = new Animated.Value(0);
    this.topAnimatedValue = new Animated.Value(400);

    this.balloonStyle = {
      position: 'absolute',
      left: 137.5,
      opacity: this._interpolateAnimation(this.opacityAnimatedValue, [0, 1], [0, 1]),
      top: this._interpolateAnimation(this.topAnimatedValue, [-999999, 999999], [-999999, 999999]),
      transform:[{scale: this._interpolateAnimation(this.scaleAnimatedValue, [0, 1], [0, 1])}]
    };
  }

  _playAnimation() {
    Animated.sequence([
      this.step1(),
      this.step2()
    ]).start();
  }

  step1() {
    return Animated.parallel([
      Animated.timing(this.opacityAnimatedValue, {
        toValue: 1,
        duration: 500
      }),
      Animated.timing(this.scaleAnimatedValue, {
        toValue: 1,
        duration: 500
      })
    ]);
  }

  step2() {
    return Animated.timing(this.topAnimatedValue, {
      toValue: 200,
      duration: 1500
    });
  }

  ...
}

插句话:在动画衔接的时候,还是纠结了一下。因为Animated提供的方法还是比较多的,这里用到了sequence、parallel,分别可以让动画顺序执行和并行。除此之外,animtaion的start方法是支持传入一个回调函数的,表示在当前动画运行结束的时候会触发这个回调。所以我们还可以这么写:

  _playAnimation() {
    this.step1(() => this.step2());	// 不同之处1:step2作为step1动画结束之后的回调传入
  }

  step1(callback) {
    Animated.parallel([
      Animated.timing(this.opacityAnimatedValue, {
        toValue: 1,
        duration: 500
      }),
      Animated.timing(this.scaleAnimatedValue, {
        toValue: 1,
        duration: 500
      })
    ]).start(() => {
      callback && callback();	// 不同之处2:调用传入的回调
    });
  }

  step2() {
    Animated.timing(this.topAnimatedValue, {
      toValue: 200,
      duration: 1500
    }).start();
  }

虽然同样能够实现效果,但是还是觉得这种方式不是很舒服,所以弃之。。。

到这里,我们已经对这个气球做了渐变、放大、平移等3项操作。但是,如果有5个气球,还有其他各种元素又该怎么办呢?这才一个气球我们就已经用了opacityAnimatedValue,scaleAnimatedValue,topAnimatedValue三个变量来控制,更多的动画元素那直就gg,不用下班了。。。

3. 实现升级

说实话,要做这么个东西,怎么就那么像在做一个PPT呢。。。

“屏幕就好比是一张PPT背景图;每一个气球就是PPT上的元素;你可以通过拖动鼠标来摆放各个气球,我可以用绝对定位来确定每个气球的位置;至于动画嘛,刚才的demo已经证明并不难实现,无非就是控制透明度、xy坐标、缩放比例罢了。”

想到这,心中不免一阵窃喜。哈哈,有路子了,可以对PPT上的这些元素封装一个通用的组件,然后提供常用的一些动画方法,剩下的事情就是调用这些动画方法组装成更复杂的动画了。新建一个PPT:“出现、飞跃、淡化、浮入、百叶窗、棋盘。。。”看着这令人眼花缭乱的各种动画,我想了下:嗯,我还是从最简单的做起吧。。。

首先,我们可以将动画分成两种:一次性动画和循环动画。
其次,作为一个元素,它可以用作动画的属性主要包括有:opacity, x, y, scale, angle等(这里先只考虑了二维平面的,其实还可以延伸扩展成三维立体的)。
最后,基本动画都可以拆解为这几种行为:出现/消失、移动、缩放、旋转。

3.1 一次性动画

想到这,反手就是创建一个新文件,代码如下:

// Comstants.js
export const INF = 999999999;

// Helper.js
export const Helper = {
  sleep(millSeconds) {
    return new Promise(resolve => {
      setTimeout(() => resolve(), millSeconds);
    });
  },
  animateInterpolate(animatedValue, inputRange, outputRange) {
    if(animatedValue && animatedValue.interpolate) {
      return animatedValue.interpolate({inputRange, outputRange});
    }
  }
};

// AnimatedContainer.js
import {INF} from "./Constants";
import {Helper} from "./Helper";

export class AnimatedContainer extends PureComponent {

  constructor(props) {
    super(props);
  }

  componentWillMount() {
    this._initAnimationConfig();
  }

  _initAnimationConfig() {

    const {initialConfig} = this.props;
    const {opacity = 1, scale = 1, x = 0, y = 0, rotate = 0} = initialConfig;

    // create animated values: opacity, scale, x, y, rotate
    this.opacityAnimatedValue = new Animated.Value(opacity);
    this.scaleAnimatedValue = new Animated.Value(scale);
    this.rotateAnimatedValue = new Animated.Value(rotate);
    this.xAnimatedValue = new Animated.Value(x);
    this.yAnimatedValue = new Animated.Value(y);

    this.style = {
      position: 'absolute',
      left: this.xAnimatedValue,
      top: this.yAnimatedValue,
      opacity: Helper.animateInterpolate(this.opacityAnimatedValue, [0, 1], [0, 1]),
      transform: [
        {scale: this.scaleAnimatedValue},
        {rotate: Helper.animateInterpolate(this.rotateAnimatedValue, [-INF, INF], [`-${INF}rad`, `${INF}rad`])}
      ]
    };
  }

  show() {}

  hide() {}

  scaleTo() {}

  rotateTo() {}

  moveTo() {}

  render() {
    return (
      <Animated.View style={[this.style, this.props.style]}>
        {this.props.children}
      </Animated.View>
    );
  }
}

AnimatedContainer.defaultProps = {
  initialConfig: {
    opacity: 1,
    scale: 1,
    x: 0,
    y: 0,
    rotate: 0
  }
};

第一步的骨架这就搭好了,简单到自己都难以置信。。。接下来就是具体实现每一个动画的方法了,先拿show/hide开刀。

show(config = {opacity: 1, duration: 500}) {
  Animated.timing(this.opacityAnimatedValue, {
    toValue: config.opacity,
    duration: config.duration
  }).start();
}

hide(config = {opacity: 0, duration: 500}) {
  Animated.timing(this.opacityAnimatedValue, {
    toValue: config.opacity,
    duration: config.duration
  }).start();
}

试了一下,简直是文美~

但是!仔细一想,却有个很严重的问题,这里的动画衔接该怎处理?要想做一个先show,然后过1s之后再hide的动画该怎么实现?貌似又回到了一开始考虑过的问题。不过这次,我却是用Promise来解决这个问题。于是代码又变成了这样:

sleep(millSeconds) {
  return new Promise(resolve => setTimeout(() => resolve(), millSeconds));
}

show(config = {opacity: 1, duration: 500}) {
  return new Promise(resolve => {
    Animated.timing(this.opacityAnimatedValue, {
      toValue: config.opacity,
      duration: config.duration
    }).start(() => resolve());
  });
}

hide(config = {opacity: 0, duration: 500}) {
  return new Promise(resolve => {
    Animated.timing(this.opacityAnimatedValue, {
      toValue: config.opacity,
      duration: config.duration
    }).start(() => resolve());
  });
}

现在我们再来看刚才的动画,只需这样就能实现:

playAnimation() {
  this.animationRef
    .show()                                 // 先出现
    .sleep(1000)                            // 等待1s
    .then(() => this.animationRef.hide());  // 消失
}

甚至还可以对createPromise这个过程再封装一波:

_createAnimation(animationConfig = []) {
  const len = animationConfig.length;
  if (len === 1) {
    const {animatedValue, toValue, duration} = animationConfig[0];
    return Animated.timing(animatedValue, {toValue, duration});
  } else if (len >= 2) {
    return Animated.parallel(animationConfig.map(config => {
      return this._createAnimation([config]);
    }));
  }
}

_createAnimationPromise(animationConfig = []) {
  return new Promise(resolve => {
    const len = animationConfig.length;
    if(len <= 0) {
      resolve();
    } else {
      this._createAnimation(animationConfig).start(() => resolve());
    }
  });
}

opacityTo(config = {opacity: .5, duration: 500}) {
  return this._createAnimationPromise([{
    toValue: config.opacity,
    duration: config.duration,
    animatedValue: this.opacityAnimatedValue
  }]);
}

show(config = {opacity: 1, duration: 500}) {
  this.opacityTo(config);
}

hide(config = {opacity: 0, duration: 500}) {
  this.opacityTo(config);
}

然后,我们再把其他的几种基础动画(scale, rotate, move)实现也加上:

scaleTo(config = {scale: 1, duration: 1000}) {
  return this._createAnimationPromise([{
    toValue: config.scale,
    duration: config.duration,
    animatedValue: this.scaleAnimatedValue
  }]);
}

rotateTo(config = {rotate: 0, duration: 500}) {
  return this._createAnimationPromise([{
    toValue: config.rotate,
    duration: config.duration,
    animatedValue: this.rotateAnimatedValue
  }]);
}

moveTo(config = {x: 0, y: 0, duration: 1000}) {
  return this._createAnimationPromise([{
    toValue: config.x,
    duration: config.duration,
    animatedValue: this.xAnimatedValue
  }, {
    toValue: config.y,
    duration: config.duration,
    animatedValue: this.yAnimatedValue
  }]);
}

3.2 循环动画

一次性动画问题就这样解决了,再来看看循环动画怎么办。根据平时的经验,一个循环播放的动画一般都会这么写:

roll() {

  this.rollAnimation = Animated.timing(this.rotateAnimatedValue, {
  	toValue: Math.PI * 2,
  	duration: 2000
  });

  this.rollAnimation.start(() => {
  	this.rotateAnimatedValue.setValue(0);
  	this.roll();
  });
}

play() {
  this.roll();
}

stop() {
  this.rollAnimation.stop();
}

没错,就是在一个动画的start中传入回调,而这个回调就是递归地调用播放动画本身这个函数。那要是对应到我们要封装的这个组件,又该怎么实现呢?

思考良久,为了保持和一次性动画API的一致性,我们可以给animatedContainer新增了以下几个函数:

export class AnimatedContainer extends PureComponent {

  ...
  
  constructor(props) {
    super(props);
    this.cyclicAnimations = {};
  }

  _createCyclicAnimation(name, animations) {
    this.cyclicAnimations[name] = Animated.sequence(animations);
  }
  
  _createCyclicAnimationPromise(name, animations) {
    return new Promise(resolve => {
      this._createCyclicAnimation(name, animations);
      this._playCyclicAnimation(name);
      resolve();
    });
  }  

  _playCyclicAnimation(name) {
    const animation = this.cyclicAnimations[name];
    animation.start(() => {
      animation.reset();
      this._playCyclicAnimation(name);
    });
  }

  _stopCyclicAnimation(name) {
    this.cyclicAnimations[name].stop();
  }

  ...
}

其中,_createCyclicAnimation,_createCyclicAnimationPromise是和一次性动画的API对应的。但是,不同点在于传入的参数发生了很大的变化:animationConfg -> (name, animations)

  1. name是一个标志符,循环动画之间不能重名。_playCyclicAnimation和_stopCyclicAnimation都是通过name来匹配相应animation并调用的。
  2. animations是一组动画,其中每个animation是调用_createAnimation生成的。由于循环动画可以是由一组一次性动画组成的,所以在_createCyclicAnimation中也是直接调用了Animated.sequence,而循环播放的实现就在于_playCyclicAnimation中的递归调用。

到这里,循环动画基本也已经封装完毕。再来封装两个循环动画roll(旋转),blink(闪烁)试试:

blink(config = {period: 2000}) {
  return this._createCyclicAnimationPromise('blink', [
    this._createAnimation([{
      toValue: 1,
      duration: config.period / 2,
      animatedValue: this.opacityAnimatedValue
    }]),
    this._createAnimation([{
      toValue: 0,
      duration: config.period / 2,
      animatedValue: this.opacityAnimatedValue
    }])
  ]);
}

stopBlink() {
  this._stopCyclicAnimation('blink');
}

roll(config = {period: 1000}) {
  return this._createCyclicAnimationPromise('roll', [
    this._createAnimation([{
      toValue: Math.PI * 2,
      duration: config.period,
      animatedValue: this.rotateAnimatedValue
    }])
  ]);
}

stopRoll() {
  this._stopCyclicAnimation('roll');
}

4. 实战

忙活了大半天,总算是把AnimatedContainer封装好了。先找个素材练练手吧~可是,找个啥呢?“叮”,只见手机上挖财的一个提醒亮了起来。嘿嘿,就你了,挖财的签到页面真的很适合(没有做广告。。。)效果图如下:

渲染元素的render代码就不贴了,但是我们来看看动画播放的代码:

startOpeningAnimation() {

  // 签到(一次性动画)
  Promise
    .all([
      this._header.show(),
      this._header.scaleTo({scale: 1}),
      this._header.rotateTo({rotate: Math.PI * 2})
    ])
    .then(() => this._header.sleep(100))
    .then(() => this._header.moveTo({x: 64, y: 150}))
    .then(() => Promise.all([
      this._tips.show(),
      this._ladder.sleep(150).then(() => this._ladder.show())
    ]))
    .then(() => Promise.all([
      this._today.show(),
      this._today.moveTo({x: 105, y: 365})
    ]));

  // 星星闪烁(循环动画)
  this._stars.forEach(item => item
    .sleep(Math.random() * 2000)
    .then(() => item.blink({period: 1000}))
  );
}

光看代码,是不是就已经脑补整个动画了~ 肥肠地一目了然,真的是美滋滋。

5. 后续思考

  1. 讲道理,现在这个AnimatedContainer能够创建的动画还是稍显单薄,仅包含了最基础的一些基本操作。不过,这也说明了还有很大的扩展空间,根据_createCyclicAnimationPromise和_createAnimationPromise这两个函数,可以自由地封装我们想要的各种复杂动画效果。而调用方就只要通过promise的all和then方法来控制动画顺序就行了。个人感觉,甚至有那么一丁点在使用jQuery。。。

  2. 除此之外,还有一个问题就是:由于这些元素都是绝对定位布局的,那这些元素的x, y坐标值怎么办?在有视觉标注稿的前提下,那感觉还可行。但是一旦元素的数量上去了,那在使用上还是有点麻烦的。。。所以啊,要是有个什么工具能够真的像做PPT一样,支持元素拖拽并实时获得元素的坐标,那就真的是文美了。。。。。。

老规矩,本文代码地址:https://github.com/SmallStoneSK/AnimatedContainer

RN自定义组件封装 - 神奇移动

1. 前言

最近盯上了app store中的动画效果,感觉挺好玩的,嘿嘿~ 恰逢周末,得空就实现一个试试。不试不知道,做完了才发现其实还挺简单的,所以和大家分享一下封装这个组件的过程和思路。

2. 需求分析

首先,我们先来看看app store中的效果是怎么样的,看下图:

哇,这个动画是不是很有趣,很神奇。为此,可以给它取个洋气的名字:神奇移动,英文名叫magicMoving~

皮完之后再回到现实中来,这个动画该如何实现呢?

我们来看这个动画,首先一开始是一个长列表,点击其中一个卡片之后弹出一个浮层,而且这中间有一个从卡片放大到浮层的过渡效果。乍一看好像挺难的,但如果把整个过程分解一下似乎就迎刃而解了。

  1. 用FlatList渲染长列表;
  2. 点击卡片时,获取点击卡片在屏幕中的位置(pageX, pageY);
  3. clone点击的卡片生成浮层,利用Animated创建动画,控制浮层的宽高和位移;
  4. 点击关闭时,利用Animated控制浮层缩小,动画结束后销毁浮层。

当然了,以上的这个思路实现的只是一个毛胚版的神奇移动。。。还有很多细节可以还原地更好,比如背景虚化,点击卡片缩小等等,不过这些不是本文探讨的重点。

3. 具体实现

在具体实现之前,我们得考虑一个问题:由于组件的通用性,浮层可能在各种场景下被唤出,但是又需要能够铺满全屏,所以我们可以使用Modal组件。

然后,根据大概的思路我们可以先搭好整个组件的框架代码:

export class MagicMoving extends Component {

  constructor(props) {
    super(props);
    this.state = {
      selectedIndex: 0,
      showPopupLayer: false
    };
  }
  
  _onRequestClose = () => {
    // TODO: ...
  }

  _renderList() {
    // TODO: ...
  }

  _renderPopupLayer() {
    const {showPopupLayer} = this.state;
    return (
      <Modal
        transparent={true}
        visible={showPopupLayer}
        onRequestClose={this._onRequestClose}
      >
        {...}
      </Modal>
    );
  }

  render() {
    const {style} = this.props;
    return (
      <View style={style}>
        {this._renderList()}
        {this._renderPopupLayer()}
      </View>
    );
  }
}

3.1 构造列表

列表很简单,只要调用方指定了data,用一个FlatList就能搞定。但是card中的具体样式,我们应该交由调用方来确定,所以我们可以暴露renderCardContent方法出来。除此之外,我们还需要保存下每个card的ref,这个在后面获取卡片位置有着至关重要的作用,看代码:

export class MagicMoving extends Component {

  constructor(props) {
    // ...
    this._cardRefs = [];
  }
  
  _onPressCard = index => {
    // TODO: ...
  };

  _renderCard = ({item, index}) => {
    const {cardStyle, renderCardContent} = this.props;
    return (
      <TouchableOpacity
        style={cardStyle}
        ref={_ => this._cardRefs[index] = _}
        onPress={() => this._onPressCard(index)}
      >
        {renderCardContent(item, index)}
      </TouchableOpacity>
    );
  };

  _renderList() {
    const {data} = this.props;
    return (
      <FlatList
        data={data}
        keyExtractor={(item, index) => index.toString()}
        renderItem={this._renderCard}
      />
    );
  }

  // ...
}

3.2 获取点击卡片的位置

获取点击卡片的位置是神奇移动效果中最为关键的一环,那么如何获取呢?

其实在RN自定义组件封装 - 拖拽选择日期的日历这篇文章中,我们就已经小试牛刀。

UIManager.measure(findNodeHandle(ref), (x, y, width, height, pageX, pageY) => {
  // x:      相对于父组件的x坐标
  // y:      相对于父组件的y坐标
  // width:  组件宽度
  // height: 组件高度
  // pageX:  组件在屏幕中的x坐标
  // pageY:  组件在屏幕中的y坐标
});

因此,借助UIManager.measure我们可以很轻易地获得卡片在屏幕中的坐标,上一步保存下来的ref也派上了用场。

另外,由于弹出层从卡片的位置展开成铺满全屏这个过程有一个过渡的动画,所以我们需要用到Animated来控制这个变化过程。让我们来看一下代码:

// Constants.js
export const DeviceSize = {
  WIDTH: Dimensions.get('window').width,
  HEIGHT: Dimensions.get('window').height
};

// Utils.js
export const Utils = {
  interpolate(animatedValue, inputRange, outputRange) {
    if(animatedValue && animatedValue.interpolate) {
      return animatedValue.interpolate({inputRange, outputRange});
    }
  }
};

// MagicMoving.js
export class MagicMoving extends Component {

  constructor(props) {
    // ...
    this.popupAnimatedValue = new Animated.Value(0);
  }

  _onPressCard = index => {
    UIManager.measure(findNodeHandle(this._cardRefs[index]), (x, y, width, height, pageX, pageY) => {
      
      // 生成浮层样式
      this.popupLayerStyle = {
        top: Utils.interpolate(this.popupAnimatedValue, [0, 1], [pageY, 0]),
        left: Utils.interpolate(this.popupAnimatedValue, [0, 1], [pageX, 0]),
        width: Utils.interpolate(this.popupAnimatedValue, [0, 1], [width, DeviceSize.WIDTH]),
        height: Utils.interpolate(this.popupAnimatedValue, [0, 1], [height, DeviceSize.HEIGHT])
      };
      
      // 设置浮层可见,然后开启展开浮层动画
      this.setState({selectedIndex: index, showPopupLayer: true}, () => {
        Animated.spring(this.popupAnimatedValue, {toValue: 1, friction: 6}).start();
      });
    });
  };
  
  _renderPopupLayer() {
    const {data} = this.props;
    const {selectedIndex, showPopupLayer} = this.state;
    return (
      <Modal
        transparent={true}
        visible={showPopupLayer}
        onRequestClose={this._onRequestClose}
      >
        {showPopupLayer && (
          <Animated.View style={[styles.popupLayer, this.popupLayerStyle]}>
            {this._renderPopupLayerContent(data[selectedIndex], selectedIndex)}
          </Animated.View>
        )}
      </Modal>
    );
  }
  
  _renderPopupLayerContent(item, index) {
    // TODO: ...
  }
  
  // ...
}

const styles = StyleSheet.create({
  popupLayer: {
    position: 'absolute',
    overflow: 'hidden',
    backgroundColor: '#FFF'
  }
});

仔细看appStore中的效果,我们会发现浮层在铺满全屏的时候会有一个抖一抖的效果。其实就是弹簧运动,所以在这里我们用了Animated.spring来过渡效果(要了解更多的,可以去官网上看更详细的介绍哦)。

3.3 构造浮层内容

经过前两步,其实我们已经初步达到神奇移动的效果,即无论点击哪个卡片,浮层都会从卡片的位置展开铺满全屏。只不过现在的浮层还未添加任何内容,所以接下来我们就来构造浮层内容。

其中,浮层中最重要的一点就是头部的banner区域,而且这里的banner应该是和卡片的图片相匹配的。需要注意的是,这里的banner图片其实也有一个动画。没错,它随着浮层的展开变大了。所以,我们需要再添加一个AnimatedValue来控制banner图片动画。来看代码:

export class MagicMoving extends Component {

  constructor(props) {
    // ...
    this.bannerImageAnimatedValue = new Animated.Value(0);
  }
  
  _updateAnimatedStyles(x, y, width, height, pageX, pageY) {
    this.popupLayerStyle = {
      top: Utils.interpolate(this.popupAnimatedValue, [0, 1], [pageY, 0]),
      left: Utils.interpolate(this.popupAnimatedValue, [0, 1], [pageX, 0]),
      width: Utils.interpolate(this.popupAnimatedValue, [0, 1], [width, DeviceSize.WIDTH]),
      height: Utils.interpolate(this.popupAnimatedValue, [0, 1], [height, DeviceSize.HEIGHT])
    };
    this.bannerImageStyle = {
      width: Utils.interpolate(this.bannerImageAnimatedValue, [0, 1], [width, DeviceSize.WIDTH]),
      height: Utils.interpolate(this.bannerImageAnimatedValue, [0, 1], [height, DeviceSize.WIDTH * height / width])
    };
  }

  _onPressCard = index => {
    UIManager.measure(findNodeHandle(this._cardRefs[index]), (x, y, width, height, pageX, pageY) => {
      this._updateAnimatedStyles(x, y, width, height, pageX, pageY);
      this.setState({
        selectedIndex: index,
        showPopupLayer: true
      }, () => {
        Animated.parallel([
          Animated.timing(this.closeAnimatedValue, {toValue: 1}),
          Animated.spring(this.bannerImageAnimatedValue, {toValue: 1, friction: 6})
        ]).start();
      });
    });
  };

  _renderPopupLayerContent(item, index) {
    const {renderPopupLayerBanner, renderPopupLayerContent} = this.props;
    return (
      <ScrollView bounces={false}>
        {renderPopupLayerBanner ? renderPopupLayerBanner(item, index, this.bannerImageStyle) : (
          <Animated.Image source={item.image} style={this.bannerImageStyle}/>
        )}
        {renderPopupLayerContent(item, index)}
        {this._renderClose()}
      </ScrollView>
    );
  }
  
  _renderClose() {
    // TODO: ...
  }
  
  // ...
}

从上面的代码中可以看到,我们主要有两个变化。

  1. 为了保证popupLayer和bannerImage保持同步的展开动画,我们用上了Animated.parallel方法。
  2. 在渲染浮层内容的时候,可以看到我们暴露出了两个方法:renderPopupLayerBanner和renderPopupLayerContent。而这些都是为了可以让调用方可以更大限度地自定义自己想要的样式和内容。

添加完了bannerImage之后,我们别忘了给浮层再添加一个关闭按钮。为了更好的过渡效果,我们甚至可以给关闭按钮加一个淡入淡出的效果。所以,我们还得再加一个AnimatedValue。。。

export class MagicMoving extends Component {

  constructor(props) {
    // ...
    this.closeAnimatedValue = new Animated.Value(0);
  }
  
  _updateAnimatedStyles(x, y, width, height, pageX, pageY) {
    // ...
    this.closeStyle = {
      justifyContent: 'center',
      alignItems: 'center',
      position: 'absolute', top: 30, right: 20,
      opacity: Utils.interpolate(this.closeAnimatedValue, [0, 1], [0, 1])
    };
  }
  
  _onPressCard = index => {
    UIManager.measure(findNodeHandle(this._cardRefs[index]), (x, y, width, height, pageX, pageY) => {
      this._updateAnimatedStyles(x, y, width, height, pageX, pageY);
      this.setState({
        selectedIndex: index,
        showPopupLayer: true
      }, () => {
        Animated.parallel([
          Animated.timing(this.closeAnimatedValue, {toValue: 1, duration: openDuration}),
          Animated.spring(this.popupAnimatedValue, {toValue: 1, friction: 6, duration: openDuration}),
          Animated.spring(this.bannerImageAnimatedValue, {toValue: 1, friction: 6, duration: openDuration})
        ]).start();
      });
    });
  };
  
  _onPressClose = () => {
    // TODO: ...
  }
  
  _renderClose = () => {
    return (
      <Animated.View style={this.closeStyle}>
        <TouchableOpacity style={styles.closeContainer} onPress={this._onPressClose}>
          <View style={[styles.forkLine, {top: +.5, transform: [{rotateZ: '45deg'}]}]}/>
          <View style={[styles.forkLine, {top: -.5, transform: [{rotateZ: '-45deg'}]}]}/>
        </TouchableOpacity>
      </Animated.View>
    );
  };
  
  // ...
}

3.4 添加浮层关闭动画

浮层关闭的动画其实肥肠简单,只要把相应的AnimatedValue全都变为0即可。为什么呢?因为我们在打开浮层的时候,生成的映射样式就是定义了浮层收起时候的样式,而关闭浮层之前是不可能打破这个映射关系的。因此,代码很简单:

_onPressClose = () => {
  Animated.parallel([
    Animated.timing(this.closeAnimatedValue, {toValue: 0}),
    Animated.timing(this.popupAnimatedValue, {toValue: 0}),
    Animated.timing(this.bannerImageAnimatedValue, {toValue: 0})
  ]).start(() => {
    this.setState({showPopupLayer: false});
  });
};

3.5 小结

其实到这儿,包括展开/收起动画的神奇移动效果基本上已经实现了。关键点就在于利用UIManager.measure获取到点击卡片在屏幕中的坐标位置,再配上Animated来控制动画即可。

不过,还是有很多可以进一步完善的小点。比如:

  1. 由调用方控制展开/收起浮层动画的运行时长;
  2. 暴露展开/收起浮层的事件:onPopupLayerWillShow,onPopupLayerDidShow,onPopupLayerDidHide
  3. 支持浮层内容异步加载
  4. ...

这些小点限于文章篇幅就不再展开详述,可以查看完整代码。

4. 实战

是骡子是马,遛遛就知道。随便抓了10篇简书上的文章作为内容,利用MagicMoving简单地做了一下这个demo。让我们来看看效果怎么样:

5. 写在最后

做完这个组件之后最大的感悟就是,有些看上去可能比较新颖的交互动画其实做起来可能肥肠简单。。。贵在多动手,多熟悉。就比如这次,也是更加熟悉了Animated和UIManager.measure的用法。总之,还是小有成就感的,hia hia hia~

老规矩,本文代码地址:

https://github.com/SmallStoneSK/react-native-magic-moving

chrome插件开发 - github仓库star趋势图

1. 前言

这天,在逛github(就是划水)的时候,突然想看看某个仓库的star走势,但是在star列表中翻了半天愣是没找到相应的功能。于是乎,谷歌一搜,发现有个叫Star History的谷歌插件,然而竟然要收费。。。

于是,又接着搜索,发现了这个仓库。好巧的是,这个仓库就是那个插件的源码。稍微瞅了下源码,感觉我也能行?

由于之前就想学学怎么写chrome插件,本着学习的态度和好奇心驱使(都是划水,没有什么不同),于是也做了一个可以查看仓库Star趋势的插件。效果如下:

效果图

2. 准备工作

2.1 chrome插件简单入门

由于也是第一次写Chrome插件,作为小白,就先搜搜大家都是怎么写chrome插件的吧。果然,一搜一大堆。。。不过,最终还是选择了官方文档,毕竟是第一手资料,虽然是英文,但写得还算通俗易懂,阅读起来没啥问题。

这里推荐看Getting Started,非常友好,一步步教你完成一个最简单的修改网页背景颜色的Chrome插件。跟着教程完成之后你就会发现,原来Chrome插件就像完成一个web项目一样。

manifest.json是项目的配置文件(类似于package.json),插件所需要的一些能力(例如Storage)就在这个文件中声明。剩下的工作,无非就是根据Chrome插件提供的API实现你想要的功能即可。

我们来看下要创建的项目目录manifest.json配置文件:

├── README.md
├── dist
│   └── bundle.js
├── images
│   ├── trending128.png
│   ├── trending16.png
│   ├── trending32.png
│   └── trending48.png
├── manifest.json
├── package.json
├── src
│   └── injected.js
└── webpack.config.js
{
  "name": "Github-Star-Trend",
  "version": "1.0",
  "manifest_version": 2,
  "description": "Generates a star trend graph for a github repository",
  "icons": {
    "16": "images/trending16.png",
    "32": "images/trending32.png",
    "48": "images/trending48.png",
    "128": "images/trending128.png"
  },
  "content_scripts": [
    {
      "matches": ["https://github.com/*"],
      "js": ["dist/bundle.js"]
    }
  ]
}

这里需要解释一点,根据最一开始我们看到的效果图,可以发现我们正在浏览的页面上多了一个Star Trend按钮。所以我们要完成的插件需要能够往页面注入一个按钮,而这正是通过manifest.json中的content_scripts字段实现的。它允许我们往matches字段匹配的网页中注入js字段中的脚本文件。

因此,上面的配置意思很简单,就是在匹配到url是https://github.com/* 的网页时,注入我们dist目录下的bundle.js文件。而bundle.js其实是我们为了在项目中用上ES6而采用webpack编译得到的,源码就是src/injected.js。接下来的工作就是在我们的src目录下开发就行了(都是写js,没什么不同)。

2.2 Github API

在正式进入开发之前,我们再来体验下Github的API调用。官方文档在这儿,概览看完之后,经过一番搜索,终于找到我们的主角Starring APi

根据这个API,我们可以拿到某个仓库的Star列表。仔细看文档,能够看到有这么一条:

You can also find out when stars were created by passing the following custom media type via the Accept header:

Accept: application/vnd.github.v3.star+json

太棒了,这不正是我们所需的star时间吗?赶紧打开postman测试一把:

postman-example.png

果然,我们顺利拿到了star仓库的时间。不过这里有一个问题,这个请求每次返回的个数只有30条,也就是说假如像react这样十几万star的仓库岂不是要请求3k+次。。。而且,还有另外一个重要的问题,那就是Github API对调用的频率也有限制。。。

postman-rate-limit.png

在上面的图片中,Response Header中告诉我们limit是60次,remaning还有59次。再发几次请求会发现,remaning一直在持续减少。。。在翻阅了一番文档之后,我找到了这个

For API requests using Basic Authentication or OAuth, you can make up to 5000 requests per hour. For unauthenticated requests, the rate limit allows for up to 60 requests per hour. Unauthenticated requests are associated with the originating IP address, and not the user making requests.

其中明确提到,它会根据ip来限制API调用的频次。对于未授权的访问,一小时最多60次;而授权的访问,一小时最多5000次。所以,为了尽可能避免的访问频次带来的问题,我们在请求中需要带上access_token。有关access_token,你可以在这里申请。

3. 开工

经过前期的一番调研,事实证明想法确实可以实现。我们再来简单理下思路:

  1. 根据页面的dom结构,找到注入Star Trend按钮的位置(injected.js)
  2. 给Star Trend按钮绑定点击事件,发起获取Star时间的请求,收集数据(fetchHistoryData.js)
  3. 根据返回的数据,利用echart.js绘制趋势图(createChart.js)

3.1 injected.js

chrome-dom-inspect.png

利用chrome的元素审查功能,我们可以很轻松地找到要注入按钮的位置,并给它绑定上相应的点击事件。

/**
 * star趋势按钮点击事件
 */
function onClickStarTrend() {
  // todo: 发起请求
  console.log('u click star trend');
}

/**
 * 创建star趋势按钮
 */
const createStarTrendBtn = () => {
  const starTrendBtn = document.createElement('button');
  starTrendBtn.setAttribute('class', 'btn btn-sm');
  starTrendBtn.innerHTML = `Star Trend`;
  starTrendBtn.addEventListener('click', onClickStarTrend);
  return starTrendBtn;
};

/**
 * 注入star趋势按钮
 */
const injectStarTrendBtn = () => {
  var newNode = document.createElement('li');
  newNode.appendChild(createStarTrendBtn());
  var firstBtn = document.querySelector('.pagehead-actions > li');
  if(firstBtn && firstBtn.parentNode) {
    firstBtn.parentNode.insertBefore(newNode, firstBtn);
  }
};

(function run() {
  injectStarTrendBtn();
}());

如果你已经安装了本地的这个插件,这个时候刷新页面你会发现多了一个Star Trend的按钮,点击的时候会在控制台打印出u click star trend的字样。

3.2 fetchHistoryData.js

获取数据首先要解决的就是构造请求url,根据文档所示,我们需要当前的仓库信息。这个倒是简单,直接上正则从当前的location.href中匹配出来即可:

const repoRegRet = location.href.match(/https?:\/\/github.com\/([^/]+\/[^/]+)\/?.*/);

然后是请求参数:

const requestConfig = {headers: {Accept: 'application/vnd.github.v3.star+json'}};

这样,我们就可以用axios发起一次请求:

const url = `https://api.github.com/repos/${repoRegRet[1]}/stargazers`;
axios.get(url, requestConfig).then(firstResponse => console.log(firstResponse));

查看log,我们成功地获取到了一个仓库第一页的star列表。不过,这里有几个问题需要解决:

  1. 如何获取第2页,第3页,第N页的star列表?
  2. 如何知道一个仓库有多少页star(即N是多少)?
  3. 当一个仓库的star数多到要发送几百次,甚至上千次请求时,如何决策?

第一个问题很好解决,在上面的url后面,跟上?page=n就表示请求第n页的star数据。

第二个问题有两种解法。一种是知道该仓库有多少star,然后除以30(一页返回30条数据)就可以知道有多少页了;还有一种方法其实API文档已经告诉我们了,第一次请求返回的数据已经告诉我们有多少页了,只不过这个数据被放在了response的headers中。其中有一个link字段:

<https://api.github.com/repositories/10270250/stargazers?page=2>; rel="next", <https://api.github.com/repositories/10270250/stargazers?page=1334>; rel="last"

以上就是link字段的一个例子,可以看到它包含了lastPage的url地址。因此,我们可以再次用正则提取出来:

let totalPage = 1;
const linkVal = firstResponse.headers.link;
if(linkVal) {
  const pageRegRet = linkVal.match(/next.*?page=(\d+).*?last/);
  if(pageRegRet) {
    totalPage = Math.min(pageRegRet[1], 1333);
  }
}

这里有两个坑,需要特别注意:

  1. 当star数只有1页时,link字段是没有的,所以这里需要判断一下;
  2. 不知道什么原因,lastPage的值最大是1334(即使仓库有十几万的star),且当page=1334发起请求时会失败。因此,totalPage最大也只能是1333。

第三个问题其实并没有完美的解决方法,通过第二个问题我们知道最多需要发1333次请求。姑且不论服务器是否对访问频次是否有限制,这么多的请求所需要的耗时其实也是不能接受的,那么怎么办呢?对于一个趋势图,其实我们没必要用成千上万的点来绘制,也许我们只用10个点(可以做成配置)来绘制就够了。因此,我们只要用均分的策略从[1, totalPage]中选取10个page就可以了。看代码:

// 最多10个请求
const URL_NUM = 10;

// 构造待请求的urls
const urls = new Array(totalPage - 1).fill(1).slice(0, URL_NUM - 1).map((_, idx) => {
  let page = idx + 2;
  if(totalPage > URL_NUM) {
    page = Math.round(page / URL_NUM * totalPage);
  }
  return {page, url: `https://api.github.com/repos/${repoRegRet[1]}/stargazers?page=${page}`};
});

// 构造请求
const requests = [
  {page: 1, request: Promise.resolve(firstResponse)},
  ...urls.map(item => ({page: item.page, request: axios.get(item.url, requestConfig)}))
];

// 发起请求
Promise.all(requests.map(_ => _.request)).then(responses => console.log(responses));

到这儿,请求数据的问题基本都已经解决了。不过还有一个容易忽视的坑,那就是由于lastPage最大只能到1333,所以当仓库的star数大于3990时,我们拿到的数据其实是少于该仓库真实的star数。因此针对这种情况,我们还需要调用这个API接口拿到仓库的基本信息,也就知道了这个仓库的总star数。

至此,我们拿到了可以构造趋势图的数据(这里就不贴构造图的数据的代码,完整代码可以点这里查看)。

3.3 createChart.js

首先,我们把injected.js中的onClickStarTrend这个坑先给填上:

let chart = createChart();
function onClickStarTrend() {
  chart.show();
  fetchHistoryData(location.href).then(data => {
    chart.ready(data);
  }).catch(err => {
    chart.fail(err);
  });
}

从上面的代码中,我们可以看到chart需要暴露出3个方法:

  1. show:展示loading状态
  2. ready:展示图表
  3. fail:展示错误信息

所以代码框架可以搭成这样:

class Chart {

  show() {
    this.node = document.createElement('div');
    this.node.style = "";					// 添加合适的样式
    this.loadingNode = document.createElement('div');
    this.loadingNode.innerHTML = "";		// 用一个svg动画,增加趣味性
    this.node.appendChild(this.loadingNode);
    document.body.appendChild(this.node);
  }
  
  ready(data) {
    this.node.innerHTML = `<div id="chart"/>`;
    ECharts.init(document.getElementById('chart')).setOption({
      color: '#40A9FF',
      title: {text: 'STAR TREND'},
      xAxis:  {
        type: 'time',
        boundaryGap: false,
        splitLine: {show: false}
      },
      yAxis: {type: 'value'},
      tooltip: {trigger: 'axis'},
      series: [{
        data,
        type: 'line',
        smooth: true,
        symbol: 'none',
        name: 'star count'
      }]
    });
  }
  
  fail(err) {
    this.node.innerHTML = "";				// 错误节点内容
  }
}

限于篇幅,这里就不贴详细的dom节点代码,完整版可以看这里。而对于echarts的配置和使用,也可以参考官网上的例子

4. 完结

整个插件的制作过程,到这儿基本上就已经完了。其他的还有网络请求异常(例如由于访问频次被限制)和设置AccessToken没有详细介绍,不过这些都是错误处理的步骤,大体上不影响插件的使用。如果想了解更多的,也可以直接看源码

回过头再来看,这次划水也算有所收获,既体验了一把chrome插件开发,也学到了Github API的调用。虽然用到的都只是一些冰山一角,不过也算是开了个头,为以后的*操作打下基础。

5. 参考

  1. chrome插件官方文档
  2. timqian/star-history
  3. Github API rate limiting
  4. Github API - starring
  5. Github API - repos

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.