详解Flutter混排瀑布流解决方案

背景

流式布局,这是一种当前无论是前端,还是Native都比较流行的一种页面布局。特别是对于商品这样的Feeds流,无论是淘宝,京东,美团,还是闲鱼。都基本上以多列瀑布流进行呈现,容器列数固定,然后每个卡片高度不一,形成参差不齐的多栏布局。

对于Native来说,无论是iOS还是Android,CollectionView和RecyclerView都能满足我们的绝大部分场景了。不过目前闲鱼很多业务场景都是在Flutter上进行实现的,当时Flutter官方只提供了ListView和GridView的实现,没有对瀑布流进行支持。

目前社区中有两个开源的解决方案,分别是WaterFallFlow和FlutterStaggeredGridView。但是在闲鱼的场景中都有一些无法满足的痛点。前者无法支持RecyclerView中StaggeredGridLayoutManager中setFullSpan这样的横跨全屏的横条卡片混排能力能力,后者在不提前预设置卡片高度的情况下有比较严重的性能问题,以及在多Sliver的场景下会有滚动错误的功能性问题。而在目前闲鱼的业务中,无论是搜索结果还是首页的同城页面,都会有混排瀑布流的需求。

所以我们决定参考RecyclerView中StaggeredGridLayoutManager的布局思路实现一套支持普通流式卡片和横跨全屏的横条卡片混排的流式布局,如图所示:

原理分析与布局流程

其实瀑布流布局和ListView和GridView一样,就是按照不同的策略将多个卡片进行尺寸计算和位置计算,然后将它们排列到一起,组成一个超过一屏,可滚动的布局。所以整个布局策略包括两个过程,首先是对卡片进行尺寸计算,计算结果决定了卡片在滚动布局中的大小。然后卡片进行位置计算,计算结果决定了卡片在滚动布局中的坐标。有了大小和坐标,就可以完成整个滚动容器的布局。下面我会对网格布局(GridView)和瀑布流布局(FlowView)的布局策略进行一个对比,让大家能更清楚的了解布局过程的细节。

Flutter中网格布局整个布局的源码都在flutter/lib/src/rendering/sliver_grid.dart的performLayout方法中,我们下面跟着源码来分析一下整个布局流程。感兴趣的同学也可以结合源码食用本文,风味更佳。

网格布局

尺寸计算过程

我们先来分析一下网格布局的卡片尺寸计算过程。这是一个GridView的常用初始化参数,我省略了一些和尺寸计算无关的参数。

GridView.count({
 @required int crossAxisCount,
 double childAspectRatio = 1.0,
})

影响布局的参数其实就是crossAxisCount(列数)和childAspectRatio(卡片纵横比)。有了这两个参数其实卡片的尺寸就很好计算了,首先先用crossAxisCount来对屏幕宽度进行等分,确定卡片的宽度,然后我们再根据这个childAspectRatio参数来计算得到卡片的高度。网格布局的卡片尺寸就可以确定下来了。计算过程如图所示:

位置计算过程

在端侧,因为一个滚动容器中的卡片数量可能会非常大,所以我们不可能对所有的卡片都进行布局,内存和运算时间都是无法接受的。我们只会布局在屏幕中以及缓存区里的卡片,之外的卡片我们会进行回收。等用户向下滑动的时候,把屏幕下方的卡片创建并布局,然后把已经划出屏幕的卡片进行回收。向上滑动的过程也是一样。所以我们会对从上到下和从下到上的位置计算过程进行分析。

我们先分析从上到下布局的过程。对于网格布局来说,每一个卡片的宽度和高度都是在位置计算流程开始之前就可以提前计算得出的。我们暂且把每个卡片的左上角叫做布局坐标点,我们来分析一下网格布局中这个坐标如何计算得出。

我们先来计算一下纵坐标,我们用卡片的index对crossAxisCount进行整除,然后再用结果乘上卡片的高度,就可以得到卡片的纵坐标了。

对于横坐标,我们已经根据crossAxisCount来对屏幕宽度进行了等分,那么每个卡片的横坐标就很容易得到了,我们用卡片的index对crossAxisCount进行整除取余,这样就能得到卡片在某一行中的顺序(即第几列),然后再乘上卡片的宽度,这样就可以得到卡片的横坐标了。

例如列数为2,卡片宽度和高度都为100的一个网格布局,那么第四个卡片(index为3)的横坐标为(3%2)×100为1,纵坐标为 (3~/ 2)×100为100,所以坐标为(100,100)。

计算过程如图所示:

整个布局关键源码如下:

// 卡片尺寸计算
final double usableCrossAxisExtent = constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1);
  final double childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount;
  final double childMainAxisExtent = childCrossAxisExtent / childAspectRatio;

// 卡片坐标计算
SliverGridGeometry getGeometryForChildIndex(int index) {
 final double crossAxisStart = (index % crossAxisCount) * crossAxisStride; //横坐标
 return SliverGridGeometry(
  scrollOffset: (index ~/ crossAxisCount) * mainAxisStride, //纵坐标
  crossAxisOffset: _getOffsetFromStartInCrossAxis(crossAxisStart),
  mainAxisExtent: childMainAxisExtent,
  crossAxisExtent: childCrossAxisExtent,
 );
} 

// 对卡片进行遍历布局
for (int index = indexOf(firstChild) - 1; index >= firstIndex; --index) {
   final SliverGridGeometry gridGeometry = layout.getGeometryForChildIndex(index); //获取尺寸和位置信息
   final RenderBox child = insertAndLayoutLeadingChild(
    gridGeometry.getBoxConstraints(constraints),
   ); //使用计算好的尺寸信息来限制卡片大小
   final SliverGridParentData childParentData = child.parentData;
   childParentData.layoutOffset = gridGeometry.scrollOffset; //卡片的纵轴坐标赋值
   childParentData.crossAxisOffset = gridGeometry.crossAxisOffset; // 卡片的横轴坐标赋值
   assert(childParentData.index == index);
   trailingChildWithLayout ??= child;
   trailingScrollOffset = math.max(trailingScrollOffset, gridGeometry.trailingScrollOffset);
  }

由此可见,网格布局中,每个卡片的位置坐标跟index是有一一对应关系的。所以无论是向下滚动对后面的卡片进行布局,还是向上滚动对前面的卡片进行布局。都使用这个策略就可以得出所有卡片的坐标。

瀑布流布局

尺寸计算过程

然后我们对瀑布流布局的卡片尺寸计算过程进行分析,反推出我们需要传入的初始化参数。首先,我们需要考虑到在瀑布流布局中一共有两种卡片,一种是宽度由屏幕宽度被布局列数均分的普通卡片,另一种是宽度充满整个屏幕的特殊卡片,我们后续叫它横条卡片。我们会分别对这两种卡片进行尺寸计算。

普通卡片

首先对于普通卡片来说,卡片的尺寸宽度和网格布局中的卡片一样,是由列数和屏幕宽度决定的,所以我们同样需要crossAxisCount这个参数。宽度确定之后,我们需要确定卡片的高度。在瀑布流布局中,每个卡片的高度是不同的,这也是瀑布流布局和网格布局最大的区别。所以我们其实可以由每个卡片自己决定自己的高度,也就是我们不需要在布局初始化的时候传入类似childAspectRatio这样影响卡片的参数。不过我们在实际的业务场景中,通常会对某些特殊位置的卡片进行特殊的高度设置,例如两列流中横条卡片上面的两个卡片,UED会有保证这两个卡片的底部位置一致的需求,不然就会造成卡片之间的裂隙,影响观感。所以我们需要一个定义了一个方法参数mainAxisExtentBuilder。

typedef double IndexedMainAxisExtentBuilder(int index);

这是一个返回值为double的方法参数,瀑布流在布局的时候会根据index尝试获取开发者在这个方法中的返回值,如果这个返回值为null,就用卡片自己内部的布局来决定卡片高度,反之就用这个返回值来决定卡片高度。计算过程如图所示:

横条卡片

横条卡片在高度的确定流程上是和普通卡片一致的,只是横条卡片的宽度总是和屏幕宽度一致,不受crossAxisCount限制。计算过程如图所示:

所以我们只需要在布局过程中能够区分这两种卡片,就可以用不同的策略对它们的尺寸进行计算。类似于mainAxisExtentBuilder,我们定义了一个IndexedFullSpanBuilder参数。

typedef bool IndexedFullSpanBuilder(int index);

这是一个返回值为bool的方法参数,瀑布流在布局的时候会根据index尝试获取开发者在这个方法中的返回值,如果这个返回值为null或者false,就使用普通卡片的宽度计算策略,反之就使用横条卡片的宽度计算策略。

所以我们就定义好了瀑布流布局初始化中确定布局的三个参数。

FlowView.count({
 @required int crossAxisCount,
 IndexedFullSpanBuilder fullSpanBuilder,
 IndexedMainAxisExtentBuilder mainAxisExtentBuilder,
})

这样我们就能够计算出布局中每一个卡片的尺寸了,接下来我们只需要再确定卡片左上角的坐标,这样就可以完成卡片的布局了。

位置计算过程

对于瀑布流来说,位置计算过程会比网格布局复杂得多,我们先来分析一下从上到下布局的过程。之前我们说过,在混排瀑布流布局中会有两种卡片,横条卡片和普通卡片。我们希望卡片的布局中尽量没有间隙。

所以对于普通卡片来说,卡片的纵坐标计算过程是这样的。我们需要在已经完成布局的卡片中进行查找,找到其中纵坐标+卡片高度(即卡片bottom纵坐标)值最小的卡片,我们把这张卡片叫做最低卡片。然后把下一张卡片布局在最低卡片的正下方,所以下一张卡片的纵坐标就是最低卡片的纵坐标+卡片高度。因为需要布局在最低卡片的正下方,所以横坐标就直接和最低卡片的横坐标保持一致即可。

对于横条卡片来说,因为他的宽度总是和屏幕宽度一致,所以我们只需要计算它的纵坐标。它的横坐标永远是0,他的纵坐标和普通卡片刚好相反,需要在已经完成布局的卡片中进行查找,找到其中纵坐标+卡片高度(即卡片bottom纵坐标)值最大的卡片,我们把这张卡片叫做最高卡片。然后把横条卡片布局在这张最高卡片下面,否则这张横条卡片会遮住其他卡片。在这里我们根据列数生成一个初始值都为0的纵坐标列表,每布局一个卡片就把该列的offset加上卡片的高度。

计算过程如图所示:

而从下到上的布局过程,瀑布流和GridView和ListView都不太一样,ListView,上一个卡片的位置可以由下一个卡片布局位置来确定,往上滚动的时候,我们只用把卡片布局在最上面的卡片上面就可以了,GridView直接根据index就可以完成计算了,瀑布流比较特殊,因为卡片的布局依赖于它上面的卡片的布局信息,无法通过后一个卡片的布局信息推断出前一个卡片的布局。在这里,一般有两种处理方式。

维护一个index和crossAxisIndex一一对应的Map关系表

目前RecyclerView和WaterFallFlow是采用这种方式的,在用户向下滑动时,正常布局,然后记录下每张卡片属于哪一列。然后在用户向上滑动时,对即将进行布局的卡片,先通过这个关系表得到它属于哪一列,然后将它布局在这一列最上面卡片的上方,这样就可以保证卡片的布局对于用户来说始终是一致的。但是这样的方式在混排瀑布流中,需要对横条卡片做特殊处理,因为横条卡片的上一张卡片不一定和横条卡片在布局上是紧贴着的,可能会有间隙。所以我们还需要记录横条卡片跟上一张卡片的间隙,布局的时候再加上这个间隙再布局,这样才能保证正确布局。

使用分页思想,始终从上到下进行布局。

FlutterStaggeredGridView采用的就是这种方式,而我们实现的混排瀑布流也使用了这样的思路。我们设定一个高度PageSize,按照这个高度给整个瀑布流布局进行分页,然后维护一个pageIndex和pageInfo的对应表,每一页里记录着自己的mainAxisOffsets,以及的firstChildIndex。

第一页的mainAxisOffsets很显然是一个长度为crossAxisCount,值为0的列表。然后从上到下布局时,不断更新这个mainAxisOffsets,例如第一页在第一列布局了第一个高度为100的普通卡片,则mainAxisOffsets更新为{100,0}。然后在第二列布局了第二个高度为150的普通卡片,则mainAxisOffsets更新为{100,150}。后续我们布局了一个高度为200的横条卡片,则mainAxisOffsets更新为{350,350}。然后横条卡片和第一张卡片之间会有一个50的间隙,这个mainAxisOffsets就是下一张卡片布局的起始点。然后当有mainAxisOffsets都超过PageSize时,我们就开始分下一页。下一页的initialOffsets就是上一页的mainAxisOffsets,然后再开始第二页的卡片布局。

这样当我们向上滚动时,当我们需要对上一个卡片进行布局时,我们就会从这个卡片所属的页面的第一个卡片开始布局,这样就瀑布流就始终是从上到下布局的。就能保证布局的正确性。

然后我们按照RenderSliverGrid的思路实现了一个RenderSliverFlow。整个布局的关键的源码如下:

// 卡片坐标计算

SliverFlowGeometry getGeometryForChildIndex(int index,List<double> startOffsets) {
 bool isFullSpan = _getIsFullSpan(index); //是否是横条卡片

 double maxOffset = startOffsets.reduce(math.max); //最高卡片底部纵坐标
 double minOffset = startOffsets.reduce(math.min); //最低卡片底部纵坐标

 var scrollOffset = minOffset;
 var crossAxisIndex = startOffsets.indexOf(minOffset); //属于哪一列
 int needCrossAxisCount = isFullSpan ? crossAxisCount : 1;

 if(isFullSpan){
  scrollOffset = maxOffset;
  crossAxisIndex = 0;
 }

 if (reverseCrossAxis) {
  crossAxisIndex = crossAxisCount - needCrossAxisCount - crossAxisIndex;
 }
 var crossAxisOffset = crossAxisIndex * crossAxisStride;
 var mainAxisExtent = _getChildMainAxisExtent(index);
 return SliverFlowGeometry(
  scrollOffset: scrollOffset, //纵坐标
  crossAxisOffset: crossAxisOffset, //横坐标
  mainAxisExtent: mainAxisExtent,
  crossAxisExtent: crossAxisStride * needCrossAxisCount - crossAxisSpacing,
  isFullSpan: isFullSpan,
  crossAxisIndex: crossAxisIndex,
 );
}

内存回收和性能优化

回收机制

前文中我们提到过,在端侧,因为一个滚动容器中的卡片数量可能会非常大,所以我们不可能一次性对所有的卡片都进行布局和绘制,内存和运算时间都是无法接受的。

我们总是希望只布局尽可能少的卡片,我们先来分析一下最晚可以从哪个卡片开始布局。从上文我们知道,我们将整个瀑布流进行了分页,每一页包含着多个卡片,我们记录着每一页的起始offsets,所以我们需要找可见区域最上方的卡片,把这个卡片的位置标记为firstIndex,然后从这个卡片所属的页面的第一个卡片开始布局。然后我们再分析一下布局在什么时候结束,因为我们前面的卡片无需依赖后面的卡片,所以我们布局到可视区域之外就可以停止布局了,然后把最后一张卡片的位置标记为lastIndex。每一次布局都会产生一个firstIndex和lastIndex。

当我们往下滑动的时候,我们会判断firstIndex属于哪一页,这就表明这一页此时在最上方,那对这一页之前的Page里的卡片我们就可以进行内存回收了。往上滑动的时候,我们把lastIndex之后的卡片全部进行回收就好了。

性能优化

这样的分页机制虽然是能够保证布局的正确性,但是其实很多情况下,我们都需要布局缓存区以外的卡片,举个极端情况的例子,可见区域的第一张卡片是属于某一个分页的最后一张卡片,这个时候我们就不得不把这个分页里的全部卡片都进行布局。这其实会对滑动性能造成一些影响,一开始的设计PageSize固定为一个屏幕的高度,每一屏分一页。后来进行了性能优化,我们会根据大部分瀑布流的卡片高度得到一个分页值,尽量保证每一次分页所包含的卡片尽可能就是一行的卡片数。这样可见区域的第一张卡片往往就是这个分页的第一张卡片,这样一来就可以减少不必要的布局。

然后我们对GridView和FlowView进行了性能测试,使用脚本对两个滚动容器分别往下滚动五次,再滚动五次。最后得出性能数据,然后我们主要关注两个数据,分别是最大丢帧数和最差帧耗时,这往往就是最影响体感的两个数据。通过根据平均卡片尺寸高度动态调整分页,最后的性能数据达到了尽可能和GridView一致。使用同一机型,性能测试数据如下:

效果与落地

这是目前使用FlowView完成的一个Demo工程,支持了Flutter滚动体系里的各种功能。scrollController(滚动到offset),reverse(逆序排列),scrollDirection(滚动方向垂直或水平滚动)等。

在闲鱼工程中,主要在首页、搜索结果页等进行落地。不过目前Flutter首页在线上只是进行了少量的灰度。

总结与展望

整个瀑布流目前结合PowerScrollView进行了初步落地,在整个布局的过程中,在功能上可扩展和优化的地方依然存在。

在可扩展的功能方面,未来希望可以在一个布局中完成不同列数的混排,例如一个Sliver中可以有一列、两列、三列、甚至六列的混排,类似于RecyclerView中的GridLayoutManager。

然后在性能方面,希望之后能够在布局逻辑中进行优化,尽可能减少不必要的计算和布局。能够在滑动中提供更好的体感。

希望官方之后会对这样比较常用的布局进行支持,这样也可以给后面的布局优化带来思路。

到此这篇关于详解Flutter混排瀑布流解决方案的文章就介绍到这了,更多相关Flutter混排瀑布流内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

时间: 2021-03-31

flutter Container容器实现圆角边框

本文实例为大家分享了flutter Container容器实现圆角边框的具体代码,供大家参考,具体内容如下 在这里使用 Container 容器来实现圆角矩形边框效果 1 圆角矩形边框 Container( margin: EdgeInsets.only(left: 40, top: 40), //设置 child 居中 alignment: Alignment(0, 0), height: 50, width: 300, //边框设置 decoration: new BoxDecoration

Flutter持久化存储之数据库存储(sqflite)详解

前言 数据库存储是我们常用的存储方式之一,对大批量数据有增.删.改.查操作需求时,我们就会想到使用数据库,Flutter中提供了一个sqflite插件供我们用于大量数据执行CRUD操作.本篇我们就来一起学习sqflite的使用. sqflite是一款轻量级的关系型数据库,类似SQLite. 在Flutter平台我们使用sqflite库来同时支持Android 和iOS. sqflite使用 引入插件 在pubspec.yaml文件中添加path_provider插件,最新版本为1.0.0,如下:

Flutter实现页面切换后保持原页面状态的3种方法

前言: 在Flutter应用中,导航栏切换页面后默认情况下会丢失原页面状态,即每次进入页面时都会重新初始化状态,如果在initState中打印日志,会发现每次进入时都会输出,显然这样增加了额外的开销,并且带来了不好的用户体验. 在正文之前,先看一些常见的App导航,以喜马拉雅FM为例: 它拥有一个固定的底部导航以及首页的顶部导航,可以看到不管是点击底部导航切换页面还是在首页左右侧滑切换页面,之前的页面状态都是始终维持的,下面就具体介绍下如何在flutter中实现类似喜马拉雅的导航效果 第一步:实

Flutter中http请求抓包的完美解决方案

前言 前阵子有同学反馈Flutter中的http请求无法通过fiddler抓包,作者喜欢使用Charles抓包工具,于是抽时间写了个小demo测试了一下,结论是在手机上设置代理,Charles确实抓不到请求数据包.于是对该问题进行了分析: 确定使用的是http发起的get请求,理论上http协议应该可以被Charles抓到包的,如果没有抓到包,那可能是没有走代理,于是乎通过将笔记本连接的wifi断开测试了一下手机上APP发起http请求,发现请求成功,证实确实没有走代理: 为什么http请求没有

Flutter进阶之实现动画效果(一)

上一篇文章我们了解了Flutter的动画基础,这一篇文章我们就来实现一个图表的动画效果. 首先,我们需要创建一个新项目myapp,然后把main.dart的内容替换成下面的代码 import 'package:flutter/material.dart'; import 'dart:math'; void main() { runApp(new MyApp()); } class MyApp extends StatelessWidget { @override Widget build(Bui

详解Flutter WebView与JS互相调用简易指南

本文采用Flutter官方WebView插件:https://pub.dartlang.org/packages/webview_flutter WebView与JS互相调用是一个刚需,但是貌似现在大家写的文章讲的都不是很清楚,我这个简易指南简单粗暴地分为两部分:JS调用Flutter和Flutter调用JS,拒绝花里胡哨,保证一看就懂,一学就会. 开始之前先简单了解一下官方WebView所包含的API: onWebViewCreated:在WebView创建完成后调用,只会被调用一次: ini

Flutter 超实用简单菜单弹出框 PopupMenuButton功能

相信在实际开发过程当中,肯定少不了这样的功能: 点击 AppBar 右上角的按钮,弹出一个菜单供用户选择. 幸运的是,Flutter 提供给我们了一个 Widget,直接就能实现如上的效果. PopupMenuButton 还是老规矩,先看官方的说明: Displays a menu when pressed and calls onSelected [1] when the menu is dismissed because an item was selected. The value pa

Android 多种简单的弹出框样式设置代码

简介 这是一个基于AlertDialog和Dialog这两个类封装的多种弹出框样式,其中提供各种简单样式的弹出框使用说明.同时也可自定义弹出框. 项目地址:http://www.github.com/jjdxmashl/jjdxm_dialogui 特性 1.使用链式开发代码简洁明了 2.所有的弹出框样式都在DialogUIUtils这个类中完成,方便查阅方法 3.可以自定义弹出框字体样式 4.简单的类似加载框的样式可以支持两种主题更改默认白色和灰色 截图 demo下载 demo apk下载 D

Android 简单的弹出框(在屏幕中间,传string[],根据内容框框大小自适应)

先给大家展示效果图: 实现代码也很简单,代码如下所示: private void showLabelAlert() { new AlertDialog.Builder(上下文) .setTitle("选择标签") .setItems(addressLabels, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { if (wh

js简单的弹出框有关闭按钮

复制代码 代码如下: <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <meta http-equiv=&qu

详解用vue编写弹出框组件

前言 最近研究了用vue编写弹出框的组件,发现其实这里面的门道还是有很多的.这篇文完全是用来记录总结下最近的学习成果,同时也希望能够帮得上正在学习纠结的你~ps:本文假设你已经了解vue2.0相关框架,因此适合有一定vue2.0基础的同学阅读. 设计组件的思考 其实单纯的编写一个弹出框组件并不难,写一个模板,然后用v-if或者v-show指令还控制组件的出现与消失.真正困扰我的是,这个组件的调用方式,这个问题纠结了我好久. 调研了下资料,有些人建议,直接把组件标签插进模板中,然后通过直接控制组件

基于JavaScript实现弹出框效果

弹出框在网站页面中是必不可少的一部分,今天借助我们平台给大家分享使用js实现简单的弹出框效果,本文写不好,还请见谅! 首先我们来分析弹出框的部件.简单弹出框分为头,内容,尾部. 头部中有标题和关闭按钮,内容就可以图文,媒体,iframe,flash等等,尾部就是按钮(确认,取消等等),这个例子尾部我就不加入按钮了,这个例子主要是实现弹出框的核心部分. <!DOCTYPE html> <html> <head> <meta charset="UTF-8&q

PopupWindow仿微信浮层弹出框效果

最近公司项目需要实现类似微信的浮层弹出框.研究发现是用PopupWindow实现的.而且可以自定义位置以及出现和退出时的动画,由于太晚了就不实现动画了,需要得同学请自己研究下.由于本人新手其中的不足和缺点请见谅. 代码如下: 首先是定义顶部按钮的main.xml文件 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.an

Android使用popUpWindow带遮罩层的弹出框

上次项目中实现了新功能,就一直想添加到博客里来着,惰性发作起来简直太可怕,不说了,跟着一起写吧,三步即可实现简单的弹出框功能,首先看效果-- 首先:主页面布局,触发控件一定要有,再有就是给根标签设置id <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:

Vue组件化开发之通用型弹出框的实现

本文主要分享关于组件化开发的理解,让刚入门的小伙伴少走一些弯路,提高开发效率,作者本人也是新手,如有不当之处,请大佬指出,感谢. 相信很多刚入门的小伙伴,经常会写很多重复的代码,而这些代码一般情况下也都是大同小异,在这种情况下,如何让开发和学习变得更加高效,组件化的思想就显得尤为重要.这里通过设计一个简单的弹出框,给小伙伴们分享组件化的应用. 组件&组件化 组件化是对某些可以进行复用的功能进行封装的标准化工作.组件一般会内含自身的内部UI元素.样式和JS逻辑代码,它可以很方便的在应用的任何地方进

Angular实现一个简单的多选复选框的弹出框指令实例

之前的文章有写过包含树结构下拉框的. 要实现一个包含多个复选框的下拉框该如何做呢? 先上个效果图吧: 代码: <!DOCTYPE html> <html ng-app="app"> <head> <meta charset="UTF-8"> <title></title> <link rel="stylesheet" type="text/css"