前言
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的高度总和。