×

路径布局-基于数学函数的视图布局方法

96
欧阳大哥2013 595a1b60 08f6 4beb 998f 2bf55e230555
2017.08.13 18:47* 字数 4084

路径布局MyPathLayoutMyLayout布局体系中的第7种布局体系,在这种布局体系中您只需要提供一个坐标轴、一个曲线函数、以及视图之间的距离这三个要素就可以构造出来一个非常酷炫的界面布局效果。在了解路径布局之前您可以看看下面几个用路径布局实现的效果实例:

路径布局效果演示图

曲线

在解析几何的课程中可以知道一个一元函数可以在二维平面坐标空间中绘制出一条对应的几何曲线来。下面是几种常见的函数的几何曲线图。

常见的函数曲线

一些应用中我们能看到一些UI界面的元素总是按照某些曲线路径来排列展示,这些特殊效果能够大大的增强用户体验以及增强界面的美观性。这些布局中视图按照某些规则排列在某些函数曲线之上,或者说我们提供一条路径曲线,然后子视图按照这条路径曲线等距离或者按照某种规则进行排列。所以基于这种规律性,我们提出了路径布局的概念。

路径布局MyPathLayout是MyLayout布局体系里面的其中一种视图布局的方法,在路径布局里面的子视图总是按照提供的一条函数曲线和一种定位的规则进行排列布局。 那么如何来构造这个曲线函数,以及如何来指定这些规则呢?

坐标轴

我们知道视图是一个矩形区域的抽象,而我们在用平面坐标进行曲线绘制时也是要求将自变量和因变量限制在某个区间当中,区间也是一个矩形区域。因此一个视图的区域是完全可以当做一个平面坐标的区域的。对于构建一个平面坐标来说,我们需要指定坐标的原点在哪里,同时我们还要指定坐标中横轴代表的是自变量还是因变量,同时我们还要指定纵轴中的值在原点以上是正数还是负数,同时我们还要指定函数曲线的自变量的开始和结束的取值区间来构建有限的平面区域。为了对坐标的表征我们抽象出了一个坐标类:

   /**
 * 坐标轴设置类,用来描述坐标轴的信息。一个坐标轴具有原点、坐标系类型、开始和结束点、坐标轴对应的值这四个方面的内容。
 */
@interface MyCoordinateSetting : NSObject


/**
 *坐标原点的位置,位置是相对位置,默认是(0,0), 假如设置为(0.5,0.5)则在视图的中间。
 */
@property(nonatomic, assign) CGPoint origin;

/**
 * 指定是否是数学坐标系,默认为NO,表示绘图坐标系。 数学坐标系y轴向上为正,向下为负;绘图坐标系则反之。
 */
@property(nonatomic, assign) BOOL isMath;

/**
 *指定是否是y轴和x轴互换,默认为NO,如果设置为YES则方程提供的变量是y轴的值,方程返回的是x轴的值。
 */
@property(nonatomic, assign) BOOL isReverse;


//开始位置和结束位置。如果设置为-CGFLOAT_MAX, CGFLOAT_MAX表示取值是正无穷和负无穷
@property(nonatomic, assign) CGFloat start;
@property(nonatomic, assign) CGFloat end;

-(void)reset;  //恢复默认设置。

@end

MyCoordinateSetting就是一个对坐标进行抽象的类,从类的定义中我们可以看出一个坐标设定的所有元素:

  • 其中的origin用来指定坐标的原点在平面区域的位置,这里的值是一个相对值,默认的(0,0)表示坐标原点位于视图平面区域的左上角,而如果您设置的值是(0.5,0.5)则表示位于视图区域的中心点的位置。
  • 其中的isMath用来指定纵坐标轴也就是y轴的值的方向。在我们学习几何课程时一般都把纵轴原点以上的值设定为正值,而把原点以下的值设定为负值。而在iOS开发中则恰恰相反。因为这个属性值默认是设置为NO的,表示纵轴的值原点往上是负数而原点往下则是正数。
  • 其中的isReverse则用来指定横轴上的值代表的是自变量还是因变量
  • 其中的start,end则用来表明坐标轴上自变量的取值区间。如果不设置则根据坐标原点设置以及视图的尺寸自动确定,因为坐标轴是一个无穷大的区域,因此我们必须要限制这个区域的大小才能映射到真实的视图矩形区域中去。

在MyPathLayout中存在一个属性:

/**
 * 坐标系设置,您可以调整坐标系的各种参数来完成下列两个方法中的坐标到绘制的映射转换。
 */
@property(nonatomic, strong, readonly) MyCoordinateSetting *coordinateSetting;

就是用来描述路径布局中所使用的坐标轴信息的,因此坐标轴是路径布局中的第一要素。

函数

当坐标轴设置完成后,我们就需要指定在坐标轴上的曲线了。我们知道在二维坐标系中的一条曲线由无数个点组成,一个点组(x,y)分别表示x轴上的数字和y轴上的数字,这些点是服从某些规则来进行排列的,而这个规则我们是可以用数学函数来描述,也就是一条曲线将对应一个数学函数。为了表示这种(x,y)点规则的数学函数,我们可以用如下三种方式来表征:

  • 直角坐标系方式: y = 𝒇(x)
  • 参数方程方式: y = 𝛗(t),x = 𝛙(t)
  • 极坐标系方式: r = 𝛒(𝛉), y = r * sin(𝛉),x = r * cos(𝛉)

具体用那种方式来描述平面坐标点,则可以根据具体的需要以及情况。在路径布局MyPathLayout中我们可以提供上面三种方程的表示:

/**
 * 直角坐标普通方程,x是坐标系里面x轴的位置,返回y = f(x)。要求函数在定义域内是连续的,否则结果不确定。如果返回的y无效则函数要返回NAN
 */
@property(nonatomic, copy) CGFloat (^rectangularEquation)(CGFloat x);


/**
 *直角坐标参数方程,t是参数, 返回CGPoint是x轴和y轴的值。要求函数在定义域内是连续的,否则结果不确定。如果返回的点无效,则请返回CGPointMake(NAN,NAN)
 */
@property(nonatomic, copy) CGPoint (^parametricEquation)(CGFloat t);

/**
 *极坐标方程,angle是极坐标的弧度,返回r半径。要求函数在定义域内是连续的,否则结果不确定。如果返回的点无效,则请返回NAN
 */
@property(nonatomic, copy) CGFloat (^polarEquation)(CGFloat angle);

上面的rectangularEquation, parametricEquation, polarEquation分别用来表示直角坐标方程函数,参数方程函数,以及极坐标方程函数。可以看出三者都是以block方式存在。因此我们只需要在block中实现不同的函数体即可。不同的函数体意味着不同的方程,在路径布局中一个时刻只能有一种函数生效。从上面提供的三个属性中我们可以得出如下规约:

  1. 每种函数中如果返回NAN则表示在这个定义域内或者值域内是无值的,也就是函数通过返回NAN来描述不连续性。
  2. 对于直角坐标方程函数来说x的值的区间由MyCoordinateSetting中的start和end来指定,默认步长是1,如果不指定开始和结束区间默认就是布局视图的尺寸作为区间。
  3. 对于参数方程函数来说t的值的区间由MyCoordinateSetting中的start和end来指定,默认步长是1,如果不指定开始和结束区间默认就是布局视图的尺寸作为区间。函数返回的一定是一个CGPoint型分别表示x和y。
  4. 对于极坐标方程函数来说angle的值是弧度值,其区间由MyCoordinateSetting中的start和end来指定,默认步长是1度。如果不指定则默认是0到2𝜋。

下面是一些常见函数的例子:

 //直线函数 y = a *x + b;     
 pathLayout.rectangularEquation = ^(CGFloat x)
 {
      return 2 * x + 3;
 };
        
 //正玄函数 y = a* sin(x);
 pathLayout.rectangularEquation = ^(CGFloat x)
 {
     return (CGFloat)(100 * sin(x / 180.0 * M_PI));
 };
       
 //摆线函数, 用参数方程: x = a * (t - sin(t); y = a *(1 - cos(t));
 pathLayout.parametricEquation = ^(CGFloat t)
 {
      CGFloat t2 = t / 180 * M_PI;  //角度转化为弧度。
      CGFloat a = 50;
      return CGPointMake(a * (t2 - sin(t2)), a * (1 - cos(t2)));            
};
       
//阿基米德螺旋线函数: r = a * θ   用的是极坐标。 pathLayout.polarEquation = ^(CGFloat angle)
{
   return 20 * angle;
};

//心形线 r = a *(1 + cos(θ)
pathLayout.polarEquation = ^(CGFloat angle)
{
    return (CGFloat)(120 * (1 + cos(angle)));
};

 //星型线 x = a * cos^3(θ); y =a * sin^3(θ);
pathLayout.parametricEquation = ^(CGFloat t)
{            
    return CGPointMake(150 * pow(cos(t / 180 * M_PI),3), 150 * pow(sin(t / 180 * M_PI),3));
 };

距离

当一个路径布局中的坐标和曲线函数都确定好了以后,接下来就需要确定布局中的子视图按照什么规则来进行排列布局了。我们知道函数曲线是一个连续的曲线,我们的子视图将根据添加的顺序沿着这条曲线依次排列。一般的情况下是希望里面的子视图的中心点在曲线上等距离排列。而且目前路径布局也只是支持了这种等距离排列的机制。需要注意的是这个等距离并不是两个子视图中心点之间的直线距离而是曲线距离。为此我们提供了一个路径距离的类MyPathSpace。这个类用来描述子视图之间的路径距离的类型。他的定义如下:

/**
 *子视图之间的路径距离类,描述子视图在路径上的间隔距离的类型。
 */
@interface MyPathSpace : NSObject

/**浮动距离,根据布局视图的尺寸和子视图的数量动态决定*/
+(id)flexed;

/**固定距离,len为长度,每个子视图之间的距离都是len*/
+(id)fixed:(CGFloat)len;

/**数量距离,根据布局视图的尺寸和指定的数量count动态决定。*/
+(id)count:(NSInteger)count;

@end

可以看出MyPathSpace路径距离可以支持三种类型的距离:

  • flexed 浮动距离,这个距离将会根据布局视图的尺寸和添加的子视图的数量来动态计算。也就是说子视图之间的距离会随着数量的增加和被压缩减少。
  • fixed 固定距离,这个表示无论添加多少子视图,子视图之间的距离总是一个固定的数字。
  • count 数量距离,这个值表示的是子视图之间的距离总是按照在一定布局尺寸并且某个具体的数量下决定的。flexed和count的区别是前者根据所有的子视图数量来动态计算间距,而后者则是根据指定的子视图数量来静态计算间距。

在路径布局中提供了一个如下的属性来指定布局中的子视图距离类型:

/**
 *设置子视图在路径曲线上的距离的类型,一共有Flexed, Fixed, MaxCount,默认是Flexed,
 */
@property(nonatomic, strong) MyPathSpace *spaceType;

通过上面的三要素:坐标、函数、距离我们就可以很简单的完成路径布局的工作了,你后续需要做的只是指定要添加到路径布局的子视图的尺寸就可以了,至于位置则会根据你所指定的三要素自动按照添加的顺序进行排列了。

路径布局MyPathLayout中的各种方法和属性

1. 原点视图

在实践中我们还存在一种场景就是希望某个视图排列在坐标区域的中心原点,而不是排列在曲线上,这也是可以实现的,我们可以通过如下属性:

/**
 *设置和获取布局视图中的原点视图,默认是nil。如果设置了原点视图则总会将原点视图作为布局视图中的最后一个子视图。原点视图将会显示在路径的坐标原点中心上,因此原点布局是不会参与在路径中的布局的。因为中心原点视图是布局视图中的最后一个子视图,而MyPathLayout重写了AddSubview方法,因此可以正常的使用这个方法来添加子视图。
 */
@property(nonatomic, strong) UIView *originView;

来设置原点视图,设置的原点视图将不会参与到路径曲线的排列中去,而是放置在坐标轴的原点区域位置。原点视图是一个可选的子视图,具体则需要根据界面的需求而设定。因为原点视图也是布局视图的一个子视图,因此当我们用subviews方法时得到的将是所有子视图,而我们只想要那些排列在路径曲线中的子视图(除中心原点视图)时则可以用如下属性获得:

/**
 *返回布局视图中所有在曲线路径中排列的子视图。如果设置了原点视图则返回subviews里面除最后一个子视图外的所有子视图,如果没有原点子视图则返回subviews
 */
@property(nonatomic, strong,readonly) NSArray *pathSubviews;

2. 得到路径布局中某个子视图的位置的自变量。

使用路径布局的目的是我们可以建立一些酷炫的布局效果,如果我们能够附加一些动画效果的话,那结果就更加美观了。既然路径布局是子视图沿着曲线点来布局的,那如果我们能够取得这些曲线点的信息的话,就可以用他来构建一些关键帧动画KeyFrame Animation或者Core Animation中的一些特效。

前面介绍了我们通过三种方程来构建函数,那么有时候我们希望知道某个子视图布局的那个点的自变量的值。举例来说,假如我们用极坐标构建了一个半径为20的圆函数 :r = 20, 然后子视图之间的间距我们设置为flexed。同时假如我添加了N个子视图,现在我想知道某个子视图在圆路径布局所处的角度值。那么这时候我们就可以通过如下方法来获取了:

/**
 得到子视图在曲线路径中定位时的函数的自变量的值。也就是说在函数中当值等于下面的返回值时,这个视图的位置就被确定了。方法如果返回NAN则表示这个子视图没有定位。
 @param subview 指定的子视图
 @return 返回指定子视图在曲线路径中的自变量值
 */
-(CGFloat)argumentFrom:(UIView*)subview;

这个方法的入参是某个路径布局中的子视图,而返回则是这个子视图在路径布局函数中的变量值。就上面的例子来说,他所表示的就是某个子视图在圆上的角度。因此我们可以通过这个返回值来做一些子视图角度旋转的坐标变换(通过视图的transform属性来实现)。或者角度变化动画效果等。

3. 获取两个子视图之间的路径坐标点信息。

有时候我们需要得到布局视图里面两个子视图之间的所有曲线路径点坐标,这样我们可以很方便的做一些帧动画来实现一些特殊效果。这时候可以通过下面三个方法来完成:

/**
 下面三个函数用来获取两个子视图之间的曲线路径数据,在调用getSubviewPathPoint方法之前请先调用beginSubviewPathPoint方法,而调用完毕后请调用endSubviewPathPoint方法,否则getSubviewPathPoint返回的结果未可知。
 */

/**
 开始获取子视图路径数据的方法
 @param full 表示getSubviewPathPoint获取的是否是全部路径点。如果为NO则只会获取子视图的位置的点
 */
-(void)beginSubviewPathPoint:(BOOL)full;
/**
 结束获取子视图路径数据的方法
 */
-(void)endSubviewPathPoint;

/**
 创建从某个子视图到另外一个子视图之间的路径点,返回NSValue数组,里面的值是CGPoint。
 @param fromIndex 指定开始的子视图的索引位置
 @param toIndex 指定结束的子视图的索引位置。如果有原点子视图时,这两个索引值不能算上原点子视图的索引值。
 @return 返回fromIndex到toIndex之间的所有曲线路径点数组
 */
-(NSArray<NSValue*>*)getSubviewPathPoint:(NSInteger)fromIndex toIndex:(NSInteger)toIndex;

在获取两个子视图之间的路径点数组之前,为了加速性能上处理,我们需要调用beginSubviewPathPoint方法,然后再调用getSubviewPathPoint方法,最后不再需要路径点时需要调用endSubviewPathPoint方法来释放一些内存。beginSubviewPathPoint方法中的full参数表明缓存的点是所有的路径上的点还是所有子视图的点。getSubviewPathPoint方法可以得到任意两个在路径上的子视图之间的所有路径点数组,路径点是一个CGPoint型。为了存储在NSArray上,系统把CGPoint型转化为了NSValue型来处理。这几个方法的使用具体可以参考PLTest1ViewController里面的介绍。

4.获取函数曲线路径。

既然路径布局是子视图在一条路径曲线上排列,那么就应该有方法能够得到这条路径,这可以通过如下方法:

/**
 创建布局的曲线的路径。用户需要负责销毁返回的值。调用者可以用这个方法来获得曲线的路径,进行一些绘制的工作。
 @param subviewCount 指定这个路径上子视图的数量的个数,如果设置为-1则是按照布局视图的子视图的数量来创建。需要注意的是如果布局视图的spaceType为Flexed,Count的话则这个参数设置无效。
 @return 返回指定数量的子视图的曲线路径,用户需要负责销毁返回的对象。
 */
-(CGPathRef)createPath:(NSInteger)subviewCount;

来得到一个曲线路径对象,需要注意的是你应该负责销毁这个方法返回的对象。这样你就可以通过得到的曲线路径对象来进行一些曲线的绘制了,通过曲线的绘制以及布局里面子视图的结合,就能够得到一些非常有趣的效果。另外一个方案是因为每个视图都有一个layerClass属性,路径布局也不例外,因此你可以建立一个MyPathLayout的派生类,并重载其中的layerClass方法如下:

//构建一个路径布局的派生类。
@interface MyXXXPathLayout:MyPathLayout

@end

@implementation MyXXXPathLayout
+(Class)layerClass
 {
 return [CAShapeLayer class];
 }

-(id)init
{
    self = [super init];
    if (self != nil)
    {
        CAShapeLayer *shapeLayer = (CAShapeLayer*)self.layer;
        shapeLayer.strokeColor = [UIColor redColor].CGColor;
        shapeLayer.lineWidth = 2;
        shapeLayer.fillColor = nil;  //您可以在这里设置路径曲线的颜色、大小、填充方案等等。  
    }
    
    return self;
}


@end

你需要重载layerClass 并返回一个CAShapeLayer。同时你可以在你的派生类里面设置CAShapeLayer的各种属性,这样你的布局视图里面将会出现一条你所设置的函数的路径曲线来。具体实现请参考:PLTest2ViewController

5.路径布局子视图之间的距离误差。

在路径布局中子视图之间的距离并不是直线的等间距,而是曲线的等间距,因此这里就涉及到了如何保证曲线等间距的问题。我们知道高等数学里面的微积分中有介绍,要想获得一条曲线之间两点之间的长度可以通过如下方法得到。

曲线两点之间的长度

在实现时因为数学库里面并没有对应的积分函数,而积分的本质是小区域累加,因此MyPathLayout中为了实现视图之间的等距离也是用了积分累计的方式来计算曲线长度的。这样在计算时当累加的步长设的越小,那么等距离将是越精确,否则可能会产生一些距离误差,因此我们提供了下面这个属性:

/**
  设置获取子视图距离的误差值。默认是0.5,误差越小则距离的精确值越大,误差最低值不能<=0。一般不需要调整这个值,只有那些要求精度非常高的场景才需要微调这个值,比如在一些曲线路径较短的情况下,通过调小这个值来子视图之间间距的精确计算。
 */
@property(nonatomic, assign) CGFloat distanceError;

用来设置我们在计算时允许的距离误差值。这个误差值不能设置为0,而且值越小,误差也越小,当然也更加消耗计算性能。因此这里默认设置为0.5 。这个属性的应用主要是用在哪些区域小而子视图数量多的场景里面,具体可以参考:PLTest4ViewController中的例子。

总结

路径布局的知识已经介绍完毕。在界面布局时我们除了能用路径布局外MyLayout布局体系还分别提供了线性布局、相对布局、表格布局、框架布局、流式布局、浮动布局一共七种布局,在我的简书里面都有对各种布局进行介绍的文档。具体要使用那种布局来进行界面布局,就需要具体的根据你的需求和界面效果图来完成。总之遇到问题,欢迎大家及时找我交流和解答。


最后欢迎大家访问我的github站点,关注欧阳大哥2013

MyLayout布局系列
Web note ad 1