前言
TangramKit是iOS系统下用Swift编写的第三方界面布局框架。他集成了iOS的AutoLayout和SizeClass以及Android的五大容器布局体系以及HTML/CSS中的float和flex-box的布局功能和思想,目的是为iOS开发人员提供一套功能强大、多屏幕灵活适配、简单易用的UI布局解决方案。Tangram的中文即七巧板的意思,取名的寓意表明这个布局库可以非常灵巧和简单的解决各种复杂界面布局问题。他的同胞框架:MyLayout是一套用objective-C实现的界面布局框架。二者的主体思想相同,实现原理则是通过扩展UIView的属性,以及重载layoutSubviews
方法来完成界面布局,只不过在一些语法和属性设置上略有一些差异。可以这么说TangramKit是MyLayout布局库的一个升级版本。大家可以通过访问下面的github站点去下载最新的版本:
- Swift版本TangramKit: https://github.com/youngsoft/TangramKit
- OC版本MyLayout: https://github.com/youngsoft/MyLinearLayout
所见即所得和编码之争以及屏幕的适配
在我10多年的开发生涯中,大部分时间都工作在客户端上。从DOS到Windows再到UNIX再到2010年接触iOS开发这6年多的时间中,总感觉一无所获,原因呢是觉没有什么积累。作为一个以编程为职业的人来说如果不留下什么可以值得为大家所知的东西的话,那将是一种职业上的遗憾。
就像每个领域都有工作细分一样,现在的编程人员也有明确分工:有一部分人做的是后端开发的工作,而有一部分人做的是前端开发的工作。二者相辅相成而完成了整个系统。后端开发的重点在于实现高性能和高可用,在数据处理上通常都是一个输入一个加工然后一个输出;而前端开发的重点在于实现界面流畅性和美观性,在数据处理上往往是多个输入一个加工和多个输出。在技术层面上后端处理的对象是多线程多进程以及数据,而前端处理的对象则是图形绘制和以及界面布局和动画特效。
这篇文章的重点是介绍界面布局的核心,因此其他部分就不再展开去说了。对于一个UI界面来说,好的界面布局体系往往能起到事半工倍的作用。PC设备上因为屏幕总是够大,比如VB,VF,PB,Dephi,AWT,Swing等语言或者环境下的应用开发非常方便,IDE环境中提供一个所见即所得的开发面板(form),人们只要使用简单的拖拉拽动作就可把各种界面元素加入到form中就可以形成一个小程序了。而开发VC程序则相对麻烦,系统的IDE环境对可视化编程的支持没有那么的完善,因此大部分界面的构建都需要通过编码来完成。同时因为PC设备屏幕较大而且标准统一,因此几乎不存在界面要在各种屏幕尺寸适配的问题。唯一引起争议是可视化编程和纯代码编程的方式之争,这种争议也体现在iOS应用的开发身上,那就是用XIB和SB以及纯代码编写界面的好坏争议。关于这个问题个人的意见是各有各好:XIB/SB进行布局时容易上手且所见即所得,但缺乏灵活性和可定制化;而纯代码则灵活性高可定制化强,缺点是不能所见即所得和代码维护以及系统分层模糊。
再回到屏幕适配的话题来说,如果说PC时代编程屏幕尺寸适配不是很重要的工作,那么到了移动设备时代则不一样了,适配往往成为整个工作的重点和难点。主要的原因是设备的屏幕尺寸和设备分辨率的多样性的差异,而且要求在这么小的屏幕上布局众多的要素,同时又要求界面美观和友好的用户体验,这就非常考验产品以及UI/UE人员和开发人员的水平,同时这部分工作也占用了开发者的大部分时间。在现有的两个主流的移动平台上,Android系统因为本身硬件平台差异性的原因,为了解决这些差异性而设计了一套非常方便的和友好的界面布局体系。它提出了布局容器的概念,也就是有专门职责的布局容器视图来管理和排列里面的子视图,根据实际中的应用场景而把这些负责布局的容器视图分类抽象出了线性布局、相对布局、框架布局、表格布局、绝对布局这5大容器布局,而这些也就构成了Android系统布局体系的核心实现。也正是这套布局机制使得Android系统能够方便的胜任多种屏幕尺寸和分辨率在不同硬件设备上的UI界面展示。而对于iOS的开发人员来说,早期的设备只有单一的3.5in大小且分辨率也只有480x320和960x640这两种类型的设备,因此开发人员只需要采用绝对定位的方式通过视图的frame
属性设置来实现界面的布局,根本不需要考虑到屏幕的适配问题。但是这一切从苹果后续依次发布iPhone4/5/6/7系列的设备后被打破了,整个iOS应用的开发也需要考虑到多屏幕尺寸和多分辨率的问题了,这样原始的frame
方法进行布局设置将不能满足这些多屏幕的适配问题了,因此iOS提出了一套新的界面布局体系:AutoLayout以及SizeClass. 这套机制通过设置视图之间的位置和尺寸的约束以及对屏幕尺寸进行分类的方式来完成界面的布局和屏幕的适配工作。
尽管如此, 虽然两个移动端平台都提供了自己独有且丰富的界面布局体系,但对于移动客户端开发人员来说界面布局和适配仍然是我们在开发中需要重点关注的因素之一。
布局的核心
我们知道,在界面开发中我们直接操作的对象是视图,视图可以理解为一个具有特定功能的矩形区块,因此所谓的布局的本质就是为视图指定某个具体的尺寸以及指定其排列在屏幕上的位置。因此布局的动作就分为两个方面:一个是指定视图的尺寸,一个是指定视图的位置。
视图的尺寸
视图的尺寸就是指视图矩形块的大小,为了表征视图的大小我们称在屏幕水平方向的尺寸大小为宽度,而称在屏幕垂直方向的尺寸大小为高度,因此一个视图的尺寸我们就可以用宽度和高度两个维度的值来描述了,宽度和高度的单位我们称之为点。UIView中用bounds
属性的size部分来描述视图的尺寸(bounds属性的origin部分后面会介绍到)。 对于屏幕尺寸来说同样也用宽度和高度来描述。在视图层次体系结构中的顶层视图的尺寸和屏幕的尺寸是一致的,为了描述这个特殊的顶层视图我们将这个顶层根视图称之为窗口,窗口的尺寸和屏幕的尺寸一样大,同时窗口是一切视图的容器视图。一个视图的尺寸我们可以用一个具体的数值来描述,比如某个视图的宽度和高度分别为:100x200。我们称这种定义的方式为绝对值类型的尺寸。但是在实际中我们的一些视图的尺寸并不能够一开始就被明确,原因是这些视图的尺寸大小和其他视图的尺寸大小有关,也就是说视图的尺寸依赖于另外一个视图或者另外一组视图。比如说有A和B两个视图,我们定义A视图的宽度和B视图的宽度相等,而A视图的高度则是B视图高度的一半。也就是可以表述为如下:
A.bounds.size.width = B.bounds.size.width
A.bounds.size.height = B.bounds.size.height /2
//父视图S的高度等于里面子视图A,B的高度的总和
S.bounds.size.height = A.bounds.size.height + B.bounds.size.height
我们称为这种尺寸的定义方式为相对值类型的尺寸。在相对值类型的尺寸中, 视图某个维度的尺寸所依赖的另外一个视图可以是它的兄弟视图,也可以是它的父视图,也可以是它的子视图,甚至可以是它自身的其他维度。 这种视图尺寸的依赖关系是可以传递和递归的,比如A依赖于B,而B右依赖于C。 但是这种递归和传递关系不能形成一个闭环依赖,也就是说在依赖关系的最终节点视图的尺寸的值必须是一个绝对值类型或者特定的相对值类型(wrap包裹值),否则的话我们将形成约束冲突而进入死循环的场景。
视图的尺寸之间的依赖关系还有两种特定的场景:
- 某个视图的尺寸依赖于里面所有子视图的尺寸的大小或者依赖于视图内所展示的内容的尺寸,我们称这种依赖为包裹(wrap)。
- 某个视图的尺寸依赖于所在父视图的尺寸减去其他兄弟视图所占用的尺寸的剩余尺寸也就是说尺寸等于父视图的尺寸和其兄弟视图尺寸的差集,我们称这种依赖为填充(fill)。
可以看出包裹和填充尺寸是相对值类型中的两种特殊的类型,他所依赖的视图并不是某个具体的视图,而是一些相关的视图的集合。
为了表征视图的尺寸以及尺寸可以设置的值的类型,我们就需要对尺寸进行建模,在TangramKit框架中TGLayoutSize
类就是一个尺寸类,这个类里面的equal方法则是用来设置视图尺寸的各种类型的值:包括绝对值类型,相对值类型,以及包裹和填充的值类型等等。同时我们对UIView扩展出了两个属性tg_width, tg_height
分别用来表示视图的布局宽度和布局高度。他其实是对原生的视图bounds
属性中的size部分进行了扩充和延展。原始的bounds
属性中的size部分只能设置绝对值类型的尺寸,而不能设置相对值类型的尺寸。
视图的位置
当一个视图的尺寸确定后,接下来我们就需要确定视图所在的位置了。所谓位置就是指视图在屏幕中的坐标位置,屏幕中的坐标分为水平坐标也就是x轴坐标,和垂直坐标也就是y轴坐标。而这个坐标原点在不同的系统中有区别:iOS系统采用左手坐标系,原点都是在左上角,并且规定y轴在原点以下是正坐标轴,而原点以上是负坐标轴,而x轴则在原点右边是正坐标轴,原点左边是负坐标轴。OSX系统则采用右手坐标系,原点在左下角,并且规定y轴在原点以上是正坐标轴,而在原点以下是负坐标轴,而x轴则在原点右边是正坐标轴,原点左边是负坐标轴。
因此视图位置的确定我们需要考虑两个方面的问题:一个是位置是相对于哪个坐标系?一个是视图内部的哪个部位来描述这个位置?
确定一个视图的位置时总是应该有一个参照物,在现有的布局体系中一般分为三种参照物:屏幕、父视图、兄弟视图。
-
第一种以屏幕坐标系作为参照来确定的位置称为绝对位置,也就是以屏幕的左上角作为原点,每个视图的位置都是距离屏幕左上角原点的一个偏移值。这种绝对位置的设置方式的优点是所有视图的参照物都是一致的,便于比较和计算,但缺点是对于那些多层次结构的视图以及带滚动效果的视图来说位置的确定则总是需要进行动态的变化和计算。比如某个滚动视图内的所有子视图在滚动时都需要重新去计算自己的位置。
-
第二种以父视图坐标系作为参照来确定的位置称为相对位置,每个子视图的位置都是距离父视图左上角原点的一个偏移值。这样的好处就是每个子视图都不再需要关心屏幕的原点,而只需要以自己的父视图为原点进行位置的计算就可以了,这种方式是目前大部分布局体系里面采用的定位方式,也是最方便的定位方式,缺点是不同层次之间的视图的位置在进行比较时需要一步步的往上进行转换,直到转换到在窗口中的位置为止。我们称这种以父视图坐标系为原点进行定位的位置称为边距,也就是离父视图边缘的距离。
- 第三种以兄弟视图坐标系作为参照来确定的位置称为偏移位置,子视图的位置是在关联的兄弟视图的位置的基础之上的一个偏移值。比如A视图在B视图的右边偏移5个点,则表示为A视图的左边距离B视图的右边5个点的距离。我们称这种坐标体系下的位置为间距,也就是指定的是视图之间的距离作为视图的位置。采用间距的方式进行定位只适合于同一个父视图之间的兄弟视图之间的定位方式。
上面的三种定位方式各有优缺点,我们可以在实际中结合各种定位方式来完成视图的位置设定。
上面我们介绍了定位时位置所基于的坐标系,因为视图并不是一个点而是一个矩形区块,所以我们必须要明确的是视图本身这个区块的哪个点来进行位置的设定。 在这里我们就要介绍视图内的坐标系。我们知道视图是一个矩形的区域,里面由无数个点构成。假如我们以视图左上角作为坐标原点的话,那么视图内的任何一点都可以用水平方向的坐标值和垂直方向的坐标值来表示。对于水平方向的坐标值来说最左边位置的点的坐标值是0,最右边位置的点的坐标值是视图的宽度,中间位置的坐标点的值是宽度的一半,对于垂直方向的坐标值来说最上边位置的点的坐标值是0,最下边位置的点的坐标值是视图的高度,中间位置的坐标点的值是高度的一半。我们称这几个特殊的坐标点为方位。因此一个视图一共有9个方位点分别是:左上、左中、左下、中上、中中、中下、右上、右中、右下。
通过对方位点的定义,我们就不再需要去关心这些点的具体的坐标值了,因为他描述了视图的某个特定的部位。而为了方便计算和处理,我们一般只需要指出视图内某个方位点在参照视图的坐标系里面的水平坐标轴和垂直坐标轴中的位置就可以完成视图的位置定位了,因为只要确定了这个方位点的在参照视图坐标系里面的位置,就可以计算出这个视图内的任意的一个点在参照视图坐标轴里面的位置。所谓的位置定位就是把一个视图内坐标系的某个点的坐标值映射为参照视图坐标系里面的坐标值的过程。
iOS中UIView提供了一个属性center
,center
属性的意义就是定义视图内中心点这个方位在父视图坐标系中的坐标值。我们再来考察一下UIView的bounds
属性,上面的章节中我们有介绍bounds
中的size部分用来描述一个视图的尺寸,而origin部分又是用来描述什么呢? 我们知道在左手坐标系里面,一个视图内的左上角方位的坐标值就是原点的坐标值,默认情况下原点的坐标值是(0,0)。但是这个定义不是一成不变的,也就是说原点的坐标值不一定是(0,0)。一个视图bounds
里面的origin部分所表达的意义就是视图内左上角的坐标值,size部分所表达的意义就是视图本身的尺寸。这样我们就可以通过下面的公式得出一个视图内9个方位(再次强调方位的概念是一个视图内的坐标点的位置)的坐标值:
左上方位 = (A.bounds.origin.x, A.bounds.origin.y)
左中方位 = (A.bounds.origin.x, A.bounds.origin.y + A.bounds.size.height / 2)
左下方位 = (A.bounds.origin.x, A.bounds.origin.y + A.bounds.size.height)
中上方位 = (A.bounds.origin.x + A.bounds.size.width/2, A.bounds.origin.y)
中中方位 = (A.bounds.origin.x + A.bounds.size.width/2, A.bounds.origin.y + A.bounds.size.height/2)
中下方位 = (A.bounds.origin.x + A.bounds.size.width/2, A.bounds.origin.y + A.bounds.size.height)
右上方位 = (A.bounds.origin.x + A.bounds.size.width, A.bounds.origin.y)
右中方位 = (A.bounds.origin.x + A.bounds.size.width,A.bounds.origin.y + A.bounds.size.height/2)
右下方位 = (A.bounds.origin.x + A.bounds.size.width,A.bounds.origin.y + A.bounds.size.height)
对于位置定义来说TangramKit中的TGLayoutPos
类就是一个对位置进行建模的类。TGLayoutPos类同时支持采用父视图作为参考系和以兄弟视图作为参考系的定位方式,这可以通过为其中的equal方法设置不同类型的值来决定其定位方式。为了实现视图定位我们也为UIView扩展出了3个水平方位的属性:tg_left, tg_centerX,tg_right来表示左中右三个方位对象。3垂直方位的属性:tg_top, tg_centerY,tg_bottom来表示上、中、下三个方位。这6个方位对象将比原生的center
属性提供更加强大和丰富的位置定位能力。
iOS系统的原生布局体系里面是通过bounds
属性和center
属性来进行视图的尺寸设置和位置设置的。bounds用来指定视图内的左上角方位的坐标值,以及视图的尺寸,而center则用来指定视图的中心点方位在父视图这个坐标体系里面的坐标值。为了简化设置UIView提供了一个简易的属性frame
可以用来直接设置一个视图的尺寸和位置,frame中的origin部分指定视图左上角方位在父视图坐标系里面的坐标值,而size部分则指定了视图本身的尺寸。frame
属性并不是一个实体属性而是一个计算类型的属性,在我们没有对视图进行坐标变换时(视图的transform未设置时)我们可以得到如下的frame
属性的伪代码实现:
public var frame:CGRect
{
get {
let x = self.center.x - self.bounds.size.width / 2
let y = self.center.y - self.bounds.size.height / 2
let width = self.bounds.size.width
let height = self.bounds.size.height
return CGRect(x:x, y:y, width:width, height:height)
}
set {
self.center = CGPoint(x:newValue.origin.x + newValue.size.width / 2, y: newValue.origin.y + newValue.size.height / 2)
self.bounds.size = newValue.size
}
}
综上所述,我们可以看出,所谓视图布局的核心,就是确定一个视图的尺寸,和确定视图在参考视图坐标系里面的坐标位置。为了灵活处理和计算,视图的尺寸可以设置为绝对值类型,也可以设置为相对值类型,也可以设置为特殊的包裹或者填充值类型;视图的位置则可以指定视图中的任意的方位,以及设置这个方位的点在窗口坐标系或者父视图坐标系或者兄弟坐标系中的坐标值。正是提供的这些多样的设置方式,我们就可以在不同的场景中使用不同的设置来完成各种复杂界面的布局。
Android的布局体系
屏幕尺寸、PPI、DPI
布局框架结构
layout布局文件。
5大布局类
...敬请期待
HTML/CSS的布局体系
CSS定位方式
浮动float
flex-box bootstrap
...敬请期待
iOS布局体系
frame,bounds,center
XIB和storyboard
AutoLayout和SizeClass
...敬请期待
TangramKit布局框架
在您不了解TangramKit之前,可以先通过下面一个例子来感受和体验一下TangramKit的布局构建语法:
- 有一个容器视图S的宽度是100而高度则等于由四个从上到下依次排列的子视图A,B,C,D的高度总和。
- 子视图A的左边距占用父视图宽度的20%,而右边距则占用父视图宽度的30%,高度则等于自身的宽度。
- 子视图B的左边距是40,宽度则占用父视图的剩余宽度,高度是40。
- 子视图C的宽度占用父视图的所有宽度,高度是40。
- 子视图D的右边距是20,宽度是父视图宽度的50%,高度是40。
代码实现如下:
let S = TGLinearLayout(.vert)
S.tg_vspace = 10
S.tg_width.equal(100)
S.tg_height.equal(.wrap)
let A = UIView()
A.tg_left.equal(20%)
A.tg_right.equal(30%)
A.tg_height.equal(A.tg_width)
S.addSubview(A)
let B = UIView()
B.tg_left.equal(40)
B.tg_width.equal(.fill)
B.tg_height.equal(40)
S.addSubview(B)
let C = UIView()
C.tg_width.equal(.fill)
C.tg_height.equal(40)
S.addSubview(C)
let D = UIView()
D.tg_right.equal(20)
D.tg_width.equal(50%)
D.tg_height.equal(40)
S.addSubview(D)
因为TangramKit对布局位置类和布局尺寸类的方法重载了运算符:~=、>=、<=、+=、-=、*=、/=
所以您可以用更加简洁的代码进行编写:
let S = TGLinearLayout(.vert)
S.tg_vspace = 10
S.tg_width ~=100
S.tg_height ~=.wrap
let A = UIView()
A.tg_left ~=20%
A.tg_right ~=30%
A.tg_height ~=A.tg_width
S.addSubview(A)
let B = UIView()
B.tg_left ~=40
B.tg_width ~=.fill
B.tg_height ~=40
S.addSubview(B)
let C = UIView()
C.tg_width ~=.fill
C.tg_height ~=40
S.addSubview(C)
let D = UIView()
D.tg_right ~=20
D.tg_width ~=50%
D.tg_height ~=40
S.addSubview(D)
通过上面的代码,您可以看出用TangramKit实现的布局代码和上面场景描述文本几乎相同,非常的利于阅读和理解。那么这些系统又是如何实现的呢?
实现原理
我们知道在对任何一个视图进行布局时,最终都是通过设置视图的尺寸和视图的位置来完成的。在iOS中我们可以通过UIView的bounds
属性来完成视图的尺寸设置,而通过center
属性来完成视图的位置设置。为了进行简单的操作,系统提供了frame
这个属性来简化对尺寸和位置的设置。这个过程不管是原始的方法还是后续的AutoLayout其实现的最终机制都是一致的。每当一个视图的尺寸改变或者要求重新布局时,系统都会调用视图的方法:
open func layoutSubviews()
而我们可以在UIView的派生类中重载上面的方法来实现对这个视图里面的所有子视图的重新布局,至于如何布局子视图则是需要根据应用场景而定。在编程时我们经常会用到一些视图,这种视图只是负责将里面的子视图按照某种规则进行排列和布局,而别无其他的作用。因此我们称这种视图为容器视图或者称为布局视图。TangramKit框架对种视图进行了建模而提供了一个从UIView派生的布局视图基类TGBaseLayout。这个类的作用就是专门负责对加入到其中的所有子视图进行布局排列,它是通过重载layoutSubviews方法
来完成这个工作的。刚才我们说过如何排列容器视图中的子视图是要根据具体的应用场景而定, 比如有可能是所有子视图从上往下按照添加的顺序依次排列,或者子视图按照某种约束依赖关系来进行布局排列,或者子视图需要多行多列的排列等等。因此我们对常见的布局应用场景进行了抽象,通过建立不同的TGBaseLayout的派生类来实现不同的布局处理:
- 线性布局TGLinearLayout:线性布局里面的所有子视图都按照添加的顺序依次从上到下或者依次从左到右进行排列。根据排列的方向可以分为垂直线性布局和水平线性布局。线性布局和iOS9上的UIStackView以及Android中的线性布局LinearLayout提供一样的功能。
- 框架布局TGFrameLayout: 框架布局里面的所有子视图布局时和添加的顺序无关,而是按照设定的位置停靠在布局视图的:左上、左中、左下、中上、中中、中下、右上、右中、右下、填充这个10个方位中的任何一个位置上。框架布局里面的子视图只跟框架布局视图的边界建立约束关系。框架布局和Android中的框架布局FrameLayout提供一样的功能。
- 表格布局TGTableLayout:表格布局里面的子视图可以进行多行多列的排列。在使用时要先添加行,然后再在行里面添加列,每行的列数可以随意确定。因为表格布局是线性布局TGLinearLayout的派生类,所以表格布局也分为垂直表格布局和水平表格布局。垂直表格布局中的行是从上到下,而列则是从左到右排列;水平表格布局中的行是从左到右,而列是从上到下排列的。表格布局和Android中的表格布局TableLayout以及HTML中的table,tr,td元素提供一样的功能。
- 相对布局TGRelativeLayout: 相对布局里面的子视图和添加的顺序无关,而是按照子视图之间设定的尺寸约束依赖和位置约束依赖进行布局排列。因此相对布局里面的所有子视图都要设置位置和尺寸的约束和依赖关系。相对布局和iOS的AutoLayout以及Android中的相对布局RelativeLayout提供一样的功能。
-
流式布局TGFlowLayout: 流式布局里面的子视图按照添加的顺序依次从某个方向排列,而当遇到了这个方向上的排列数量限制或者容器的尺寸限制后将会另起一行,而重新按照原先的方向依次排列。最终这个布局中的子视图将形成多行多列的排列展示。流式布局和线性布局的区别是,线性布局只是单行或者单列的,而流式布局则是多行多列。流式布局和表格布局的区别是,表格布局有明确行的概念,在使用前要添加行再添加列,而流式布局则没有明确行的概念,由布局自动生成行和列。根据排列的方向和限制的规则,流式布局分为垂直数量约束布局、垂直内容约束布局、水平数量约束布局、水平内容约束布局四种布局。流式布局实现了HTML/CSS3中的flex-box的子集的功能。
-
浮动布局TGFloatLayout:浮动布局里面的子视图按照添加的顺序,并且按照每个子视图自身设定的浮动规则向某个方向进行浮动停靠。当子视图的尺寸无法容纳到布局视图的剩余空间时,则会自动寻找一个能够容纳自身尺寸的最佳位置进行浮动停靠。浮动布局里面的子视图并不是有规则的多行多列的排列。根据子视图可以浮动的方向浮动布局分为垂直浮动布局和水平浮动布局。浮动布局和HTML/CSS中的float定位实现了相同的功能。
- 路径布局TGPathLayout: 路径布局里面的子视图按照一个提供的数学函数得到的曲线路径等距离的根据添加的顺序依次排列。所有的子视图的位置都是根据函数曲线中距离相等的点而确定的。路径布局提供了直角坐标系、参数方式、极坐标系三种曲线的构建方法。路径布局是TangramKit中的独有的一种布局。
上述的7个派生类分别的实现了大部分的不同的应用场景。在每个派生类的layoutSubviews
的实现中都按照描述的规则来设置子视图的尺寸bounds
和位置center
属性。也就是说最终的子视图的尺寸和位置是在布局视图中的layoutSubviews
中进行设置的。那么我们就必须要提供另外一套子视图的布局尺寸和布局位置的设置方法,以便在布局视图布局时将子视图设置好的布局尺寸和布局位置转化为真实的视图尺寸和视图位置。为此TangramKit专门提供了一个视图的布局尺寸类TGLayoutSize用来进行子视图的布局尺寸的设置,一个视图的布局位置类TGLayoutPos用来进行子视图的布局位置的设置。我们对UIView建立了一个extension。分别扩展出了2个布局尺寸对象和6个布局位置对象:
extension UIView
{
//左边位置
var tg_left:TGLayoutPos{get}
//上边位置
var tg_top:TGLayoutPos{get}
//右边位置
var tg_right:TGLayoutPos{get}
//下边位置
var tg_bottom:TGLayoutPos{get}
//水平中心点位置
var tg_centerX:TGLayoutPos{get}
//垂直中心点位置
var tg_centerY:TGLayoutPos{get}
//宽度尺寸
var tg_width:TGLayoutSize{get}
//高度尺寸
var tg_height:TGLayoutSize{get}
}
也就是说我们将不再直接设置子视图的bounds
和center
(这两个属性只会在布局视图中的layoutSubviews
中设置)属性了,而是直接操作UIView扩展出来的布局位置对象和布局尺寸对象。如果把布局视图的layoutSubviews
比作一个数学函数的话,那么我们就能得到如下的方程式:
UIView.center = TGXXXLayout.layoutSubviews(UIView.tg_left, UIView.tg_top, UIView.tg_right, UIView.tg_bottom,UIView.tg_centerX,UIView.tg_centerY)
UIView.bounds = TGXXXLayout.layoutSubviews(UIView.tg_width, UIView.tg_height)
因此我们可以看出不同的TGBaseLayout的派生类因为里面的布局方法不相同,而导致子视图的位置和尺寸的计算方法不同,从而得到了我们想要的效果。那么为什么要用6个布局位置对象和2个布局尺寸对象来设置子视图的位置和尺寸而不直接用bounds
和center
呢? 原因在于bounds和center只提供了有限的设置方法而布局位置对象和布局尺寸对象则提供了功能更加强大的设置方法,而这些方法又可以简化我们的编程,以及可以很方便的适配各种不同尺寸的屏幕。(还记得我们上面的例子里面,尺寸和位置可以设置为数值,.wrap, .fill,以及百分比的值吗?)。
TangramKit为了存储这些扩展的布局位置和布局尺寸对象,内部是使用了objc的runtime机制提供的动态属性创建的方法:
public func objc_getAssociatedObject(_ object: Any!, _ key: UnsafeRawPointer!) -> Any!
系统通过这个方法来关联视图对象的那6个布局位置和2个布局尺寸对象。
上面的代码中我们看到了布局容器视图通过layoutSubviews
方法来实现对子视图的重新布局。而且也提到了当容器视图的尺寸发生变化时也会激发对layoutSubviews
的调用。除了自动激发外,我们可以通过手动调用布局视图的setNeedLayout
方法来实现布局视图的layoutSubviews
调用。当我们在设置子视图的布局位置和布局尺寸时,系统内部会在设置完成后调用布局视图的setNeedLayout
的方法,因此只要对子视图的布局位置和布局尺寸进行设置都会重新激发布局视图的布局视图。那么对子视图的frame,bounds,center真实位置和尺寸的改变呢?我们也要激发布局视图的重新布局。为了解决这个问题,我们引入了KVO
的机制。布局视图在添加子视图时会监听加入到其中的子视图的frame,bounds,center的变化,并在其变化时调用布局视图的setNeedLayout
来激发布局视图的重新布局。我们知道每次当一个视图调用addSubview添加子视图时都会激发调用者的方法:didAddSubview
。为了实现对子视图的变化的监控,布局视图重载了这个方法并对子视图的isHidden,frame,center
进行监控:
override open func didAddSubview(_ subview: UIView) {
super.didAddSubview(subview)
subview.addObserver(self, forKeyPath:"isHidden", options: NSKeyValueObservingOptions.new, context: nil)
subview.addObserver(self, forKeyPath:"frame", options: NSKeyValueObservingOptions.new, context: nil)
subview.addObserver(self, forKeyPath:"center", options: NSKeyValueObservingOptions.new, context: nil)
}
override open func willRemoveSubview(_ subview: UIView) {
super.willRemoveSubview(subview)
subview.removeObserver(self, forKeyPath: "isHidden")
subview.removeObserver(self, forKeyPath: "frame")
subview.removeObserver(self, forKeyPath: "center")
}
当子视图的frame或者center变更时,将会激发布局视图的重新布局。上面曾经说过,在布局视图重新布局子视图时最终会调整子视图的bounds和center.那么这样就有可能会形成循环的重新布局,为了解决这种循环递归的情况,布局视图在layoutSubviews调用进行布局前设置了一个布局中的标志,而在所有子视图布局完成后将恢复这个布局中的标志。因此当我们布局视图通过KVO监控到子视图的位置和尺寸变化时,则会判断那个布局中的标志,如果当前是在布局中则不会再次激发布局视图的重新布局,从而防止了死循环的发生。
这就是TangramKit布局实现的原理,下面的图表列出了TangramKit的整个布局框架的类体系结构:
布局位置类和布局尺寸类
在前面的介绍布局核心的章节以及布局实现原理的章节里面我们有说道布局位置类和布局尺寸类。之所以系统不直接操作视图的bounds和center
属性而是通过扩展视图的2个布局尺寸属性和6个布局位置属性来进行子视图的布局设置。原因是后者能够提供丰富和多样的设置。而且我们在编程时也不再需要通过设置视图的frame来实现布局了,即使设置也可能会失效。
比重类TGWeight
TGWeight类的值表示尺寸或者位置的大小是父布局视图的尺寸或者剩余空间的尺寸的比例值,也就是说值的大小依赖于父布局视图的尺寸或者剩余空间的尺寸的大小而确定,这样子视图就不需要明确的指定位置和尺寸的大小了,非常适合那些需要适配屏幕的尺寸和位置的场景。 至于是父视图的尺寸还是父视图剩余空间的尺寸则要根据其所在的布局视图的上下文而确定。比如:
//假如A,b是在一个垂直线性布局下的子视图
A.tg_width.equal(TGWeight(20)) //A的宽度是父布局视图宽度的20%
A.tg_height.equal(TGWeight(30)) //A的高度是父布局视图剩余高度的30%
B.tg_left.equal(TGWeight(40)) //B的左边距是父视图宽度的40%
B.tg_top.equal(TGWeight(10)) //B的顶部间距时父视图的剩余高度的10%
为了简化和更加直观的表示比重类型的值,我们重载%运算符,这样上面的代码就可以简写为如下更加直观的方式:
//假如A是在一个垂直线性布局下的子视图
A.tg_width.equal(20%) //A的宽度是父布局视图宽度的20%
A.tg_height.equal(30%) //A的高度是父布局视图剩余高度的30%
B.tg_left.equal(40%) //B的左边距是父视图宽度的40%
B.tg_top.equal(10%) //B的顶部间距时父视图的剩余高度的10%
下面的列表中列出了在各种布局下视图的尺寸和位置的TGWeight类型值所代表的意义:
为了表示方便,我们把:
- 线性布局简称L
- 垂直线性布局简称为LV
- 水平线性布局简称为LH
- 框架布局简称为FR
- 垂直表格布局简称为TV
- 水平表格布局简称为TH
- 相对布局简称为R
- 浮动布局简称FO
- 流式布局FL
- 路径布局简称P
- 布局视图的非布局父视图S
- 所有布局简称ALL
tg_left | LV/FR/S/TH | LH/TV |
tg_top | LH/FR/S/TV | LV/TH |
tg_right | LV/FR/S/TH | LH/TV |
tg_bottom | LH/FR/S/TV | LV/TH |
tg_centerX | LV/FR/TH | - |
tg_centerY | LH/FR/TV | - |
tg_width | LV/FR/S/R/TH/P | LH/TV/FO/FL |
tg_height | LH/FR/S/R/TV/P | LV/TH/FO/FL |
布局尺寸类TGLayoutSize
布局尺寸类用来描述视图布局核心中的视图尺寸。我们对UIView扩展出了2个布局尺寸对象 :
public var tg_width:TGLayoutSize
public var tg_height:TGLayoutSize
分别用来实现视图的宽度和高度的布局尺寸设置。在TGLayoutSize类中,我们可以通过方法equal
来设置视图尺寸的多种类型的值,类中是通过重载equal方法来实现多种类型的值的设置的。
public func equal(_ size:CGFloat, increment:CGFloat = 0, multiple:CGFloat = 1) ->TGLayoutSize
public func equal(_ weight:TGWeight, increment:CGFloat = 0, multiple:CGFloat = 1) ->TGLayoutSize
public func equal(_ array:[TGLayoutSize], increment:CGFloat = 0, multiple:CGFloat = 1) ->TGLayoutSize
public func equal(_ view:UIView,increment:CGFloat = 0, multiple:CGFloat = 1) ->TGLayoutSize
public func equal(_ dime:TGLayoutSize!, increment:CGFloat = 0, multiple:CGFloat = 1) ->TGLayoutSize
上面的方法中我们可以通过equal方法来设置:
- CGFloat类型的值表示视图的尺寸是一个绝对值类型的尺寸值。比如:
A.tg_width.equal(100) //A的宽度为100 A.tg_height.equal(200) //A的高度为200
-
TGWeight类型的值表示视图的尺寸是一个依赖于父视图尺寸的相对比例值。(具体见上面TGWeight类型值的定义和使用)
//假如A是在一个垂直线性布局下的子视图 A.tg_width.equal(20%) //A的宽度是父布局视图宽度的20% A.tg_height.equal(30%) //A的高度是父布局视图剩余高度的30%
-
TGLayoutSize类型的值表示视图的尺寸和另外一个尺寸对象的值相等,这也是一种相对值类型的尺寸值,通过设置这种尺寸的依赖我们就可以不必要明确的指定一个具体的值,而是会随着所以依赖的尺寸变化而变化。设置为TGLayoutSize类型的值通常用于在相对布局中的子视图,当然也可以在其他类型的布局中使用。下面是一个展示的例子:
A.tg_width.equal(B.tg_width) //A的宽度等于B的宽度 A.tg_height.equal(A.tg_width) //A的高度等于A的宽度
-
UIView类型的值其实就是TGLayoutSize的简化版本设置,表示某个维度的尺寸值等于指定视图的相同维度的尺寸值。比如:
A.tg_width.equal(B) //表示A视图的宽度等于B视图的宽度 A.tg_height.equal(A.superview) //表示A视图的高度等于父视图的高度。
-
[TGLayoutSize]数组类型的值,只用在相对布局里面的子视图设置才有意义,其他的类型的布局中设置这种类型的值无效。他表示子视图的尺寸和数组里面的所有子视图来等分父布局视图的尺寸。比如:
//A,B,C,D都是相对布局视图里面的子视图,我们希望A,B,C,D这四个子视图来均分父视图的宽度,这样A,B,C,D都不需要明确的指定宽度了。 A.tg_width.equal([B.tg_width, C.tg_width, D.tg_width]) A.tg_width.equal(B.tg_width) //A和B的宽度相等 A.tg_width.equal([B.tg_width]) //A和B的宽度相等并且平分布局视图的宽度,也就是A,B的宽度都是布局视图的宽度的一半
-
特殊类型的值。为了简化尺寸的设置我们定义了三种特殊类型的尺寸值:
- wrap: 他表示尺寸的值由布局视图的所有子视图的尺寸或者由子视图的内容包裹而成。也就是尺寸的大小是由子视图或者视图的内容共同决定的,这样视图的尺寸将依赖其内部的子视图的尺寸或者子视图内容的大小。
-
fill: 他表示视图的尺寸的值将会填充满父视图的剩余空间,也就是说视图的尺寸值是依赖于父视图的尺寸的大小。
-
average:他表示视图的尺寸将和其兄弟视图一起来均分父视图的尺寸,这样所有兄弟视图的尺寸都将相等。
下面是这三个特殊值使用的例子:
A.tg_width.equal(.wrap) //A视图的宽度由里面的所有子视图或者内容包裹而确定。 A.tg_height.equal(.fill) //A视图的高度填充满父视图的剩余高度空间。 B.tg_width.equal(.average) //B视图的宽度将会和其他兄弟视图均分父视图的宽度。
上面列出了布局尺寸类中的equal方法可以设置的值的类型,我们还看到了方法中存在着另外两个默认的参数:increment 和multiple
这两个参数的意义表示在尺寸等于上述类型的值的基础上的增量值和倍数值。增量值默认是0,而倍数值则默认是1。比如某个子视图的宽度等于另外一个子视图的宽度值加20的时,可以通过equal方法设置如下:
A.tg_width.equal(B.tg_width, increment:20) //A的宽度等于B的宽度加20
除了可以在equal方法中指定增量值外,布局尺寸类还单独提供一个add
方法来实现增量值的设置:
public func add(_ val:CGFloat) ->TGLayoutSize
这样上述的代码也可以用如下的方式设置:
A.tg_width.equal(B.tg_width).add(20)
在equal方法中的multiple值则是指定尺寸等于另外一个尺寸的倍数值。比如某个子视图的高度等于另外一个子视图的高度的一半时,可以通过equal方法设置如下:
A.tg_height.equal(B.tg_height, multiple:0.5); //A的高度等于B的高度的一半。
除了可以在equal方法中指定倍数值外,布局尺寸类还单独提供一个multiply
方法来实现倍数值的设置:
public func multiply(_ val:CGFloat) ->TGLayoutSize
这样上述的代码也可以用如下的方式设置:
A.tg_height.equal(B.tg_height).multiply(0.5)
在布局尺寸类中我们除了可以用equal, add, multiply
方法来设置视图的尺寸依赖值以及增量和倍数外,我们还可以对视图尺寸的最大最小值进行控制处理。比如在实践中我们希望某个视图的宽度等于另外一个兄弟视图的宽度,但是最小不能小于20,而最大则不能超过父视图的宽度的一半。 这时候我们就需要用到布局尺寸类的另外两个方法了:
public func min(_ size:CGFloat, increment:CGFloat = 0, multiple:CGFloat = 1) ->TGLayoutSize
public func min(_ dime:TGLayoutSize!, increment:CGFloat = 0, multiple:CGFloat = 1) ->TGLayoutSize
public func min(_ view:UIView, increment:CGFloat = 0, multiple:CGFloat = 1) ->TGLayoutSize
public func max(_ size:CGFloat, increment:CGFloat = 0, multiple:CGFloat = 1) ->TGLayoutSize
public func max(_ view:UIView, increment:CGFloat = 0, multiple:CGFloat = 1) ->TGLayoutSize
public func max(_ dime:TGLayoutSize!, increment:CGFloat = 0, multiple:CGFloat = 1) ->TGLayoutSize
上述的两个方法min,max
分别用来设置视图尺寸最小不能小于的值以及最大不能超过的值。方法中我们可以看出最大最小值除了可以设置具体的数值外还可以设置为另外一个布局尺寸对象,同样我们还可以设置增量和倍数值。因此我们可以通过对min
和max
方法的使用来解决上述的问题:
//A的宽度等于B的宽度,最小为20,最大为父视图宽度的一半。
A.tg_width.equal(B.tg_width).min(20).max(A.superview,multiple:0.5)
最后我们列出视图的扩展属性tg_width, tg_height在各布局视图下equal方法能够设置的值的类型,我们这里设置B为一个兄弟视图,S为父视图
A.tg_width | ALL | - | FR/R/FLH/FO | FR/R/FO/P | R | ALL | R | R |
A.tg_height | ALL | FR/R/FLV/FO/LV | - | R | FR/R/FO/P | R | ALL | R |
布局位置类TGLayoutPos
布局位置类用来描述视图布局核心中的视图的位置。我们对UIView扩展出了6个布局位置对象:
public var tg_left:TGLayoutPos //视图左边布局位置
public var tg_top:TGLayoutPos //视图上边布局位置
public var tg_right:TGLayoutPos //视图右边布局位置
public var tg_bottom:TGLayoutPos //视图下边布局位置
public var tg_centerX:TGLayoutPos //视图水平中心点布局位置
public var tg_centerY:TGLayoutPos //视图垂直中心点布局位置
分别用来实现视图的水平维度的左、中、右三个方位以及视图垂直维度的上、中、下三个方位的布局位置设置。在TGLayoutPos类中,我们可以通过方法equal
来设置视图位置的多种类型的值,类中是通过重载equal方法来实现多种类型的值的设置的。
public func equal(_ origin:CGFloat, offset:CGFloat = 0) ->TGLayoutPos
public func equal(_ weight:TGWeight, offset:CGFloat = 0) ->TGLayoutPos
public func equal(_ array:[TGLayoutPos], offset:CGFloat = 0) ->TGLayoutPos
public func equal(_ view: UIView, offset:CGFloat = 0) ->TGLayoutPos
public func equal(_ pos:TGLayoutPos!, offset:CGFloat = 0) ->TGLayoutPos
我们可以通过上面定义的equal方法来设置:
-
CGFloat类型的值表示视图的位置是一个绝对值类型的位置值。 比如:
A.tg_left.equal(10) //A视图的左边位置是10 A.tg_right.equal(20) //A视图的右边位置是20 A.tg_centerX.equal(5) //A视图的水平中心点的偏移位置是5
我们知道在视图定位时位置的概念根据参考坐标系不同而不同:
- 定位的值如果是以父视图作为参考系坐标那么视图的位置就叫做边距 ,边距描述的是视图距离父视图的距离。
- 定位的值如果是以兄弟视图作为参考系坐标那么视图的位置就叫做间距,间距描述的是视图距离兄弟视图的距离(垂直线性布局中虽然第一个子视图的顶部是距离父视图但是我们仍然称为间距)。
对于绝对值类型的位置值,他所表示的意义是边距还是间距这个要看他所加入的布局视图的类型而不同。下面的列表中展示了位置在不同的布局中描述的是间距还是边距:
tg_left/tg_right | LV/FR/R/TH/S | LH/FO/FL/P/TV |
tg_top/tg_bottom | LH/FR/R/TV/S | LV/FO/FL/P/TH |
tg_centerX | LV/FR/R/TH/S | - |
tg_centerY | LH/FR/R/TV/S | - |
-
TGWeight类型的值表示视图的位置是一个依赖于父视图尺寸的相对比例值。目前只有在线性布局、框架布局、和非布局父视图中才支持这种类型的值的设置(具体见上面TGWeight类型值的定义和使用)
//假如A视图是在一个垂直线性布局里面,垂直线性布局的宽度为50 A.tg_left.equal(20%) //A视图的左边距占用父视图宽度的20%也就是10 A.tg_right.equal(30%) //A视图的右边距占用父视图宽度的30%也就是15
-
TGLayoutPos类型的值表示视图的位置依赖另外一个视图的位置。这种类型的值大部分用于在相对布局中使用的子视图,但是有几个特殊的位置就是父视图的位置是几乎在所有布局视图中都支持。比如:
A.tg_left.equal(B.tg_right) //A视图在B视图的右边 A.tg_top.equal(A.superview.tg_top) //A视图的顶部和父视图对齐 A.tg_centerX.equal(B.tg_right) //A视图的水平中心点和B视图的右边对齐
-
UIView类型的值其实就是TGLayoutPos的简化版本设置,标识某个方位的位置等于指定视图的相同方法的位置值。比如:
A.tg_left.equal(B) //A的左边位置和B的左边位置相等
-
[TGLayoutPos]数组类型的值,只能用在相对布局里面的子视图的tg_centerX,tg_centerY这两个属性的equal方法中才有意义,他表示子视图和数组里面其他所有子视图的位置在相对布局中整体水平居中或者垂直居中。比如:
//相对布局里面有A,B,C,D四个子视图,想让这四个子视图在布局视图里面整体水平居中。 A.tg_centerX.equal([B.tg_centerX,C.tg_centerX,D.tg_centerX]) A.tg_centerX.equal(B.tg_centerX) //这个意义和上面是不同的,他表示A视图的水平中心点和B视图的水平中心点是对齐的。 A.tg_centerX.equal([B.tg_centerX]) //这个表示A,B在布局视图里面整体水平居中
上面列出了布局位置类中的equal方法可以设置的值的类型,我们还看到了方法中存在着另外一个默认的参数:offset
这个参数的意义表示在位置等于上述类型的值的基础上的偏移值。偏移默认是0。比如某个子视图的左边位置等于另外一个子视图的右边的位置再往右偏移20时,可以通过equal方法设置如下:
A.tg_left.equal(B.tg_right, offset:20) //A在B视图的右边再往右偏移20
A.tg_top.equal(A.superview.tg_top, offset:20) //A在父视图顶部往下偏移20的位置
除了可以在equal方法中指定偏移量值外,布局位置类还单独提供了一个offset
方法来实现偏移量的设置:
public func offset(_ val:CGFloat) ->TGLayoutPos
这样上述的代码也可以用如下方法设置:
A.tg_left.equal(B.tg_right).offset(20)
A.tg_top.equal(A.superview.tg_top).offset(20)
通过偏移量的设置,我们可以发现那些表示的是边距意义的位置值,其实就是等于位置依赖于父视图对应位置的偏移值。比如某个子视图的左边距是20,其实就是等价于子视图的左边等于父视图的左边再偏移20。下面的代码其实是等价的。
//A是一个相对布局里面的子视图
A.tg_left.equal(20)
A.tg_left.equal(A.superview.tg_left).offset(20) //这句代码和上句是等价的
A.tg_centerY.equal(0)
A.tg_centerY.equal(A.superview.tg_centerY).offset(0) //这句代码和上句是等价的
A.tg_bottom.equal(20)
A.tg_bottom.equal(A.superview.tg_bottom).offset(20) //这句代码和上句是等价的
在布局位置类中我们除了可以用equal,offset
方法设置视图的位置依赖及偏移量外,我们还可以对视图位置的最大最小值进行控制处理。比如在实践中我们希望某个子视图的左边距等于父视图的宽度的20%,但是最小不能小于20,最大不能超过30。 这时候我们就需要用到布局位置类的另外两个方法了:
public func min(_ val:CGFloat, offset:CGFloat = 0) ->TGLayoutPos
public func max(_ val:CGFloat, offset:CGFloat = 0) ->TGLayoutPos
上述的两个方法min,max
分别用来设置视图位置最小不能小于的值以及最大不能超过的值。方法中我们可以设置一个具体的数值以及偏移量,因此我们可以通过对min
和max
方法的使用来解决上述的问题:
//A的左边距等于父视图的宽度的20%,最小为20,最大为30
A.tg_left.equal(20%).min(20).max(30)
最后我们列出子视图的6个扩展属性在各布局视图下equal方法能够设置的值的类型:
tg_left | ALL | L/FR/T/R/S | R | - |
tg_top | ALL | L/FR/T/R/S | R | - |
tg_right | ALL | L/FR/T/R/S | R | - |
tg_bottom | ALL | L/FR/T/R/S | R | - |
tg_centerX | ALL | L/FR/T/R/S | R | R |
tg_centerY | ALL | L/FR/T/R/S | R | R |