听说你想写个渲染引擎 - 布局树

大家好,我是微微笑的蜗牛,🐌。

上一篇文章我们讲了样式树的生成,确定了每个节点的样式。这一节主要介绍如何生成布局树,也就是确定每个节点的位置。

盒子模型

首先介绍一下盒子模型,因为布局都是以它为基础来展开。

简单来说,一个节点的位置区域由内容区、内边距、边框、外边距四个部分组成,如下图所示。

image

盒子类型

元素设定的 display 类型,决定了盒子类型。常用的有如下几种:

  • block,块级元素,生成块级盒子
  • inline,行内元素,生成行内级盒子
  • none,表示该元素不显示

block box

块盒子,它是一个容器。默认竖向排列,内部元素独占一行。如下所示:

inline box

行内盒子,它也是一个容器。默认横向排列,在一行内展示。当一行充满后,会折行显示。如下图所示:

anonymous box

匿名盒子比较特殊,它不与实际的元素关联,所以称为匿名。关于匿名盒子的介绍可点击文末链接 1 查看

因为盒子容器中只能包含一种类型的盒子,要么是 block,要么是 inline。当盒子中混合了不同布局类型的元素时,会产生匿名盒子。

匿名盒子又分为匿名块盒子和匿名行内盒子。

1. 匿名块盒子

匿名块盒子的产生分为两种情况。

当 block box 包含 block 和 inline 元素时,会产生一个匿名块盒子,将 inline 元素包裹在其中。

举个例子:

div, p {display: block}

<div>Some inline text <p>followed by a paragraph</p> followed by more inline text.</div>

div 和 p 都是块级元素,前后的文字是行内元素。此时会产生两个匿名块盒子,分别包裹前后的文字。如下图所示:

同样,当在 inline box 中包含了 block 元素时,也会产生匿名块盒子。

比如:

p    { display: inline }
span { display: block }

<p>
This is anonymous text before the SPAN.
<span>This is the content of SPAN.</span>
This is anonymous text after the SPAN.
</p>
  • p 生成行内盒子,span 生成块级盒子。此时,行内盒子包含块级盒子。
  • "This is anonymous text before the SPAN." 会产生匿名块盒子。
  • "This is anonymous text after the SPAN." 也会产生匿名块盒子。

如下图所示:

2. 匿名行内盒子

一种比较常见的情况,当 block box 中包含文字时,会自动生成匿名行内盒子包裹文字。

比如:

p    { display: block }

<p>Some <em>emphasized</em> text</p>

<p> 生成了块盒子,<em> 生成了行内盒子。这时会生成匿名行内盒子,分别包裹 Some 和 text。如下图所示:

数据结构

盒子模型

上边提到过,盒子模型包括内容、内边距、边框、外边距几部分。内容区是一个矩形,其余部分属于边距类型,可分别设置上下左右的值。

对于内容区来说,定义矩形结构:

struct Rect {
    var x: Float = 0.0
    var y: Float = 0.0
    var width: Float = 0.0
    var height: Float = 0.0
}

对于边距类型,定义如下:

// 边距定义
struct EdgeSizes {
    var left: Float = 0.0
    var right: Float = 0.0
    var top: Float = 0.0
    var bottom: Float = 0.0
}

那么盒子模型的定义如下:

struct Dimensions {
    // 内容区
    var content: Rect = Rect()
    
    // 内边距
    var padding: EdgeSizes = EdgeSizes()
    
    // 外边距
    var margin: EdgeSizes = EdgeSizes()
    
    // 边框
    var border: EdgeSizes = EdgeSizes()
}

接着来定义布局类型,枚举即可,关联各自的样式节点。

// 布局类型
enum BoxType {
    case AnonymousBlock
    case BlockNode(StyleNode)
    case InlineNode(StyleNode)
}

布局树

布局树节点包含盒子模型数据、布局类型、子节点。定义如下:

// 布局树
class LayoutBox {
    
    // 布局描述
    var dimensions: Dimensions
    
    // 类型
    var boxType: BoxType
    
    // 子节点布局
    var children: [LayoutBox]
}

布局

元素的布局类型由 display 属性决定,可从节点的样式表中得到。

这里我们只实现 block 节点的布局,inline 的暂且不处理。block 类型的节点会生成块级盒子,纵向排列。

那么布局究竟是要做些什么呢?其实主要是确定各个节点盒子模型中的数据,比如宽高、边距等等。

关于宽高的处理,有一些前置知识很重要。

节点的宽度是由父节点宽度来决定的,是由上至下的处理。而高度却不一样,父节点的高度由子节点的高度之和决定。它是一个由下至上的过程,必须等所有子节点高度计算出来后,父节点的高度才能确定。

整个布局过程,我们将分为 4 个步骤来处理:

  • 宽度计算。计算节点宽度,确定水平方向间距。
  • 位置计算。计算节点坐标,确定竖直方向间距。
  • 子节点布局。确定子节点布局信息,同时更新父节点高度。
  • 高度计算。获取 css 设置的高度。

宽度计算

宽度计算是最为复杂的一环。因为这跟 width、padding、border、margin 的设定有关,其中最重要的是 width 和 margin。

另外,width、margin 可以设置为 auto,表示让浏览器自行计算它们的值。

  • 当 width 为 auto 时,表示假若在有边距的情况下,尽可能的将自身宽度设置为父容器的宽度。
  • 当 margin 为 auto 时,表示浏览器选择一个合适的边距。

假设我们将 「width + margin + padding + border 之和」定义为元素所占的总宽度。如下图所示:

而总宽度和实际父容器的宽度很可能存在不匹配的情况,要么溢出,要么不足。这时候就需要根据 width、margin 各自的设置来进行调整,以满足需求。

下面,就来讲讲不同情况下的调整。

1. 首先做一些前置工作,进行数据准备。如下:

  • 从样式表中提取宽度,若 width 没有设置,默认值为 auto
  • 从样式表中提取水平边距,margin、padding、border 的 left 和 right 值。
  • 计算总宽度,totalWidth = 所有边距 + 宽度。
  • 计算剩余空间,leftSpace = 父容器宽度 - 总宽度。

2. 依据 css 中的计算方式(详情可点击文末链接 2),可分为如下几种情形:

  • 当 width 不为 auto,且总宽度 > 父容器宽度,将 margin-left/margin-right 中为 auto 的值设置为 0。

    如果 margin-left 为 auto,那么 margin-left = 0;

    如果 margin-right 为 auto,那么 margin-right = 0;

  • 当 width/margin-left/margin-right 都有设置值时(也就是都不为 auto),根据 block 的 direction 调整 margin。

    如果是 ltr,则调整 margin-right;如果是 rtl,则调整 margin-left。

    这里我们默认都是 ltr,只调整 margin-right,修改其值满足宽度要求。

  • 当 width 不为 auto,margin-left/margin-right 仅且只有一个为 auto 时,将其中为 auto 的属性设置为剩余空间。

  • 当 width 为 auto,将 margin-left/margin-right 中为 auto 的属性设置为 0,width 设置为剩余空间。

  • 当 width 不为 auto,margin-left 和 margin-right 都为 auto,两者平分剩余空间。

具体的代码处理,可查看 Layout.swift 中 calculateBlockWidth 函数。

3. 在处理完以上几种情况后,margin、width 的值已经确定,此时可以更新盒子模型数据,主要是水平方向的数据。

位置计算

这一步主要是计算坐标点,以及垂直方向的间距。

垂直方向的间距,分别取出 border、margin、padding 的 top 和 bottom 值即可。

节点的坐标,跟父容器的位置有关。如下图所示:

上图红色虚线框为子节点 2 的区域,现在我们要确定子节点 2 的坐标。

对于 x 坐标来说,比较好理解。

子节点的 x = 父容器 x + margin + border + padding

y 坐标的计算如下:

子节点的 y = 父容器 y + 父容器高度 + margin + border + padding

这里可能有些让人迷惑,为什么是加上父容器的高度?

block 是纵向排列,不应该是计算出该节点之前的全部子节点所占空间吗?

是的,没错。其实这里父容器的高度就是已经布局完成的子节点高度之和,下面的子节点布局中会提到。

子节点布局

这一步遍历子节点,递归计算子节点的布局。

// 计算子节点布局
func layoutBlockChildren() {
        for child in self.children {
            child.layout(containingBlock: self.dimensions)
                        
            // 计算整体高度
            self.dimensions.content.height += child.dimensions.marginBox().height
        }
  }

注意这里父节点高度的计算,此时会累加子节点的高度。每当布局完成一个节点,就加上它的高度。因此,父节点的高度是布局好的子节点高度之和。

高度

若节点自身设置了高度,则取其值。

// 如果设置了 height,则取该值
func calculateBlockHeight() {
    
    if let styleNode = getStyleNode() {
                
        // 获取设置的 height
        if let heightValue = styleNode.getValue(name: "height") {
            
            if case Value.Length(let height, .Px) = heightValue {
                self.dimensions.content.height = height
            }
        }
    }
}

生成布局树

遍历样式树,根据元素的布局类型,生成布局树节点,进而生成布局树。

// 递归确定每个节点的 display 数据
mutating func buildLayoutBox(styleNode: StyleNode) -> LayoutBox {
    let root = LayoutBox(styleNode: styleNode)

    for child in styleNode.children {
        switch child.getDisplay() {
        
        case .Block:
            let childLayoutBox = buildLayoutBox(styleNode: child)
            root.children.append(childLayoutBox)
            break
            
        case .Inline:
            let childLayoutBox = buildLayoutBox(styleNode: child)
            
            // inline 元素,找到 container
            let container = root.getInlineContainer()
            container.children.append(childLayoutBox)
            break
            
        default:
            break
        }
    }

    return root
}

上边的代码分别处理了 block 和 inline 元素的情况。这里需要注意,关于 inline 的处理,跟匿名盒子有关。

当 block box 中包含了 inline 元素时,会创建匿名块盒子包裹 inline 元素。排列在一起的 inline 元素,会放在同一个匿名盒子中。

如下图所示:

如果 block 的最后一个节点已经是匿名盒子,那么直接使用;否则创建一个新的盒子插入。

// 获取 inline 节点的容器。如果 block 包含一个 inline 节点,它会创建一个匿名 block 来包裹该 inline
// 所有在 block 中的排列在一起的 inline 节点,简单处理,都会放在一个匿名 block 中。
func getInlineContainer() -> LayoutBox {

    switch self.boxType {

    case .AnonymousBlock, .InlineNode(_):
        return self
        
    case .BlockNode(_):
        // 取出最后一个子节点
        let lastChild = self.children.last
        
        // 如果已经是匿名盒子,不做处理,稍后返回
        if case .AnonymousBlock = lastChild?.boxType  {
            
        } else {
            // 生成新的匿名盒子
            let anonymousBlock = LayoutBox(boxType: .AnonymousBlock)
            
            // 添加匿名匿名盒子
            self.children.append(anonymousBlock)
        }
        
        // 返回最后子节点
        return self.children.last!
    }
}

最后对布局树进行布局,确定节点位置信息。

完整代码可查看:https://github.com/silan-liu/tiny-web-render-engine-swift

总结

这一节主要介绍了关于如何确定节点的布局信息,并生成了一颗布局树。其中最为复杂的是宽度的计算,处理情况有点多。

下一节将讲述如何进行绘制,将布局信息转化为像素点,敬请期待~

最后

您要是觉得文章有帮助的话,可以点击下方名片关注公众号「微微笑的蜗牛」。

在公众号聊天框中回复「蜗牛」,可添加微信进行交流~

参考资料

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容