FlexLayout入门系列(三)

FlexLayout入门系列(三)

本篇介绍一个简单界面的布局以及如何和UIScrollView结合,还有LayoutMode的区别

为了简单处理safeArea的问题,我将会引入另外一个叫PinLayout的布局框架,和FlexLayout一样,它也是Luc开发的,也十分强大,布局思想和Flexbox不同,和Autolayout类似,有兴趣可以了解一下,我这里只用到它的safeArea处理能力,所以不作过多的讨论。

先来看看本次的界面例子,来自【摩拜】-【我的钱包】:

像这种简单界面,下面的【余额】、【我的红包】、【微信免密】也不多,没必要动用UITableView这种大杀器,直接各种UIView组合来布局一下就行了。

惯例,我还是给画一下我眼中的界面盒子结构:

整个界面含有绿框红框两个盒子,绿框有点特殊,内容可能超出框子,需要滚动,所以我们会需要一个UIScrollView

我们先忽略红框盒子,假装绿框就是整个界面,看看要怎么布局:

  • 整体内容都是左右留白,大概20
  • 其他没啥好说的,直接搞起

一般我的代码组织是这样的,ViewController代码:

class MyWalletVC: UIViewController {

var mainView: MyWalletView { return self.view as! MyWalletView }
override func loadView() { self.view = MyWalletView() }

override func viewDidLoad() {
super.viewDidLoad()

}
}

然后view的代码是这样的:

import UIKit
import FlexLayout


class MyWalletView: UIView {

fileprivate let rootFlex = UIView()

override init(frame: CGRect) {
super.init(frame: frame)
configUI()
}
required init?(coder aDecoder: NSCoder) {
fatalError("未实现")
}

func configUI() {
backgroundColor = .white
addSubview(rootFlex)

rootFlex.flex.define { flex in

}
}

func layout() {
rootFlex.flex.layout()
}

override func sizeThatFits(_ size: CGSize) -> CGSize {
rootFlex.frame.size = size
layout()
return rootFlex.frame.size
}

override func layoutSubviews() {
super.layoutSubviews()
rootFlex.frame = bounds
layout()
}
}

和我们上一篇的代码没什么大的不同,只是从VC里分离出来了,专注我们的布局:

let titleLabel = UILabel()
titleLabel.font = UIFont.systemFont(ofSize: 18, weight: .medium)
titleLabel.text = "我的钱包"
titleLabel.textColor = .black

rootFlex.flex.paddingHorizontal(20).define { flex in
flex.addItem(titleLabel).marginTop(30).marginBottom(18).backgroundColor(.cyan)
}

果然飘了,开篇我就说了,我会引入PinLayout来解决这个问题:

import PinLayout

func layout() {
rootFlex.flex.margin(pin.safeArea)
rootFlex.flex.layout()
}

So easy,妈妈再也不用担心我的safeArea~

接下来是橙红色的图片,别担心,作为一个被代码耽误了的灵魂画手,我已经用Sketch再次模仿出来了,直接用就可以了:

  • 测量得宽高比为67:40
let posterImgV = UIImageView(image: UIImage(named: "bike_bg"))

rootFlex.flex.paddingHorizontal(20).define { flex in
flex.addItem(titleLabel).marginTop(30).marginBottom(18).backgroundColor(.cyan)
flex.addItem(posterImgV).aspectRatio(67/40).marginBottom(15)
}

咦?cross-axis侧轴应该是stretch的呀,为什么没拉满?这就让我写文章的很被(da)动(lian)啊……

测试了一下,原来是.marginBottom(15)导致的,不知道什么原理(Flexbox我也不是很懂),不过不要紧,我们给它加一个.width(100%)就完美了:

flex.addItem(posterImgV).width(100%).aspectRatio(67/40).marginBottom(15)

顺带一提,FlexLayout的语法也就是声明式的,用view.flex添加的一切属性都会导致Yoga添加对应的计算条件到view里,flex.layout()的时候就根据这些条件来计算view的frame,子view的frame等。

所以,上面的链式语法其实先后没有关系,比如.width(100%).aspectRatio(67/40).marginBottom(15).marginBottom(15).width(100%).aspectRatio(67/40)效果没什么不同,只不过我个人是习惯了:

  1. 先写布局方向(direction)或定位方式(position)
  2. 再写主轴对齐方式(justifyContent)
  3. 再写侧轴对齐方式(alignItems)
  4. 然后再写尺寸(width、height)
  5. 然后是各种边距(margin、padding)
  6. 接着写比例aspectRatio
  7. 然后写grow、shrink
  8. 最后调试需要就加背景色(backgroundColor)

嗯,个人喜好而已。

回到正题,好像左上角还差个小标题呀,它是属于图片盒子里的,也不需要点击,我们给它加到图片盒子里:

let posterTitleLabel = UILabel()
posterTitleLabel.font = UIFont.systemFont(ofSize: 18, weight: .medium)
posterTitleLabel.text = "黑手单车·月卡"
posterTitleLabel.textColor = .white

rootFlex.flex.paddingHorizontal(20).define { flex in
flex.addItem(titleLabel).marginTop(30).marginBottom(18)
flex.addItem(posterImgV).width(100%).aspectRatio(67/40).marginBottom(15).define { flex in
flex.addItem(posterTitleLabel).marginTop(16).marginLeft(20)
}
}

发现字体小了点,将大标题的也加大到22才顺眼:

妈耶,左下角还有个英文,加上吧:

flex.addItem(posterImgV).width(100%).aspectRatio(67/40).marginBottom(15).define { flex in
flex.addItem(posterTitleLabel).marginTop(16).marginLeft(20)
flex.addItem(posterSubtitleLabel).position(.absolute).left(20).bottom(16)
}

不上图了,接着搞月卡剩余天数这个view:

  • 高度80
  • 内容主轴是横向row,大致分为3部分,但是3部分之间的间距没有规律,宜采用占位弹簧(个人叫法)

突然发现rootFlex的背景色应该是有点灰的……自己改一下吧

// VC增加属性
/// 月卡信息容器
fileprivate let cardInfoContainer = UIView()
let remainDaysLabel = UILabel()
let daysDetailBtn = UIButton(type: .system)


cardInfoContainer.layer.cornerRadius = 12
cardInfoContainer.layer.shadowOpacity = 0.1
cardInfoContainer.layer.shadowColor = UIColor.gray.cgColor
cardInfoContainer.layer.shadowOffset = CGSize(width: 0, height: 1)
cardInfoContainer.layer.shadowRadius = 12

let cardTitleLabel = UILabel()
cardTitleLabel.font = UIFont.systemFont(ofSize: 14)
cardTitleLabel.text = "黑手单车月卡"
cardTitleLabel.textColor = UIColor(white: 0.3, alpha: 1)

remainDaysLabel.font = UIFont.systemFont(ofSize: 14)
remainDaysLabel.text = "月卡剩余0天"
remainDaysLabel.textColor = .gray

let tipsLabel = UILabel()
tipsLabel.font = UIFont.systemFont(ofSize: 12)
tipsLabel.text = "骑行更划算!"
tipsLabel.textColor = .red

daysDetailBtn.setTitle("查看", for: .normal)
daysDetailBtn.setTitleColor(UIColor(white: 0.3, alpha: 1), for: .normal)
daysDetailBtn.titleLabel?.font = UIFont.systemFont(ofSize: 14)
daysDetailBtn.layer.cornerRadius = 8
daysDetailBtn.layer.masksToBounds = true
daysDetailBtn.backgroundColor = UIColor(white: 0.95, alpha: 1)

布局代码:

...(略)...
flex.addItem(posterImgV).width(100%).aspectRatio(67/40).marginBottom(15).define { flex in
...(略)...
}
flex.addItem(cardInfoContainer).direction(.row).padding(20, 20, 20, 14)
.backgroundColor(.white).define { flex in
// 月卡标题, 剩余天数
flex.addItem().define { flex in
flex.addItem(cardTitleLabel)
flex.addItem(remainDaysLabel)
}
// 占位
flex.addItem().grow(1).shrink(1)
// 骑行更划算
flex.addItem(tipsLabel)
// 查看按钮
flex.addItem(daysDetailBtn).marginLeft(14)
}

剩余天数和小标题直接应该要加一点垂直间距,【查看】按钮圆角半径还需要加大,文字左右还需要一点边距:

daysDetailBtn.layer.cornerRadius = 18

// 月卡标题, 剩余天数
flex.addItem().define { flex in
flex.addItem(cardTitleLabel)
flex.addItem(remainDaysLabel).marginTop(4)
}
// 占位
flex.addItem().grow(1).shrink(1)
// 骑行更划算
flex.addItem(tipsLabel)
// 查看按钮
flex.addItem(daysDetailBtn).marginLeft(14).paddingHorizontal(13)

合适了,接下来定义一个方法,便于生成三个类似cell一样的按钮,当然咯,你也可以封装一下呀,我懒,随意演示一下:

extension MyWalletView {

fileprivate func createCell(title: String, subtitle: String) -> (UIView, UIButton, UILabel) {
let btn = UIButton()
let arrow = UIImageView(image: UIImage(named: "rightArrow"))

let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = UIFont.systemFont(ofSize: 14)
titleLabel.textColor = UIColor(white: 0.3, alpha: 1)

let subtitleLabel = UILabel()
subtitleLabel.text = subtitle
subtitleLabel.font = UIFont.systemFont(ofSize: 14)
subtitleLabel.textColor = UIColor(white: 0.3, alpha: 1)

let v = UIView()
v.flex.direction(.row).paddingHorizontal(15).height(55).define { flex in
flex.addItem(titleLabel).grow(1).shrink(1)
flex.addItem(subtitleLabel).marginHorizontal(10)
flex.addItem(arrow).size(15).alignSelf(.center)
// 分隔线
flex.addItem().position(.absolute).height(0.5).width(100%).left(15).bottom(0)
.backgroundColor(UIColor(white: 0.85, alpha: 1))
flex.addItem(btn).position(.absolute).left(0).top(0).width(100%).height(100%)
}
return (v, btn, subtitleLabel)
}
}

主要用来创建一个类似cell一样的条状view,view里顶层覆盖了一个看不见的UIButton,并且把这些引用return出去。

然后声明一堆VC属性:

/// 余额
private(set) var balanceLabel: UILabel!
/// 我的红包
private(set) var bonusLabel: UILabel!
/// 微信免密状态
private(set) var wxPassStateLabel: UILabel!

/// 点击余额回调
var onBalanceClicked: (()->Void)?
/// 点击红包回调
var onBonusClicked: (()->Void)?
/// 点击微信免密回调
var onWXPassStateClicked: (()->Void)?

在布局之前生成三个假cell:

let (balanceView, balanceBtn, balanceLabel) = createCell(title: "余额", subtitle: "0.00元")
let (bonusView, bonusBtn, bonusLabel) = createCell(title: "我的红包", subtitle: "0.00元")
let (wxView, wxBtn, wxLabel) = createCell(title: "微信免密", subtitle: "未开通")

// 赋值方便以后控制
self.balanceLabel = balanceLabel
self.bonusLabel = bonusLabel
self.wxPassStateLabel = wxLabel

然后添加到布局里:

flex.addItem(cardInfoContainer).direction(.row).padding(20, 20, 20, 14)
.backgroundColor(.white).define { flex in
...(略)...
}
flex.addItem(balanceView)
flex.addItem(bonusView)
flex.addItem(wxView)

有没有感觉屌屌哒?

接下来把按钮的响应代码加上:

// 绑定点击事件
balanceBtn.addTarget(self, action: #selector(balanceButtonClick), for: .touchUpInside)
bonusBtn.addTarget(self, action: #selector(bonusButtonClick), for: .touchUpInside)
wxBtn.addTarget(self, action: #selector(wxStateButtonClick), for: .touchUpInside)


@objc fileprivate func balanceButtonClick() {
print("点击余额")
onBalanceClicked?()
}
@objc fileprivate func bonusButtonClick() {
print("点击红包")
onBonusClicked?()
}
@objc fileprivate func wxStateButtonClick() {
print("点击微信")
onWXPassStateClicked?()
}

然后去VC操作一下咯:

override func viewDidLoad() {
super.viewDidLoad()
config()
}

private func config() {
mainView.remainDaysLabel.text = "月卡剩余51天"
mainView.balanceLabel.text = "4.00元"
mainView.bonusLabel.text = "0.63元"
mainView.wxPassStateLabel.text = "已开通"

mainView.onBalanceClicked = { [unowned self] in
print(self.mainView.balanceLabel.text ?? "")
}
mainView.onBonusClicked = { [unowned self] in
print(self.mainView.bonusLabel.text ?? "")
}
mainView.onWXPassStateClicked = { [unowned self] in
print(self.mainView.wxPassStateLabel.text ?? "")
}
// 查看按钮
mainView.daysDetailBtn
.addTarget(self, action: #selector(monthCardDetailClicked), for: .touchUpInside)
}

@objc private func monthCardDetailClicked() {
print("查看月卡详情")
}

好了,VC的都是题外话,咱们关注的是布局呀布局……

再增加两个假cell模拟屏幕放不下的情况:

let (moreView1, _, _) = createCell(title: "支付宝免密", subtitle: "未开通")
let (moreView2, _, _) = createCell(title: "银联免密", subtitle: "未开通")

...(略)...
flex.addItem(wxView)
flex.addItem(moreView1)
flex.addItem(moreView2)

可以看到,subview已经超过了rootFlex的高度了。

这个时候就可以讲一下rootFlex.flex.layout()这句话的含义了。
它其实相当于rootFlex.flex.layout(mode: .fitContainer),mode取值有三种:

  • fitContainer
    子盒子会按照父盒子的宽高来布局
  • adjustHeight
    子盒子只使用父盒子的宽度来布局,最终是按照子盒子布局完之后的高度(我猜是Y值最大的那个子盒子,未实测)来设置父盒子的高度
  • adjustWidth
    子盒子只使用父盒子的高度来布局,最终是按照子盒子布局完之后的宽度来设置父盒子的宽度

按我自己的理解来说,第一种模式是子盒子根据父盒子的宽高来布局,不改变父盒子的宽高;而第二种第三种(我没用过第三种)是根据父盒子一个方向的尺寸来布局,最终撑开父盒子另一个方向的尺寸。

我们试试把mode改成.adjustHieght试试:

func layout() {
rootFlex.flex.margin(pin.safeArea)
rootFlex.flex.layout(mode: .adjustHeight)
}

这次可以看到,rootFlex的高度已经是被子盒子撑开了。

然后……这个不就刚好是contentSize吗?亲娘啊,终于要用到UIScrollView了……

// VC增加一个属性
fileprivate let mainScroll = UIScrollView()

func configUI() {
backgroundColor = .white
rootFlex.backgroundColor = UIColor(white: 0.96, alpha: 1)
addSubview(mainScroll) // 用scrollView作为容器
mainScroll.addSubview(rootFlex)
...(略)...
}

override func layoutSubviews() {
super.layoutSubviews()
mainScroll.frame = bounds
rootFlex.frame = mainScroll.bounds
layout() // 进行rootFlex布局计算
// 布局完成之后
mainScroll.contentSize = rootFlex.bounds.size
}

效果:

呀!好像还漏了一个【已交押金】的view耶……

让我们重新理顺一下盒子结构,然后才好排布呀:

之前的所有布局的子view都是放在rootFlex的,现在我们要把它们移到另外一个叫mainContainer的view里。
红框的bottomView就是【已交押金】的view了,高度是已知的。
蓝框的scrollView就是负责展示mainContainer的内容,它的高度是rootFlex出去红框之后的剩余高度。

好了,开始改造,先声明mainContainer,调整configUI的代码:

fileprivate let mainContainer = UIView()


func configUI() {
backgroundColor = .white
mainContainer.backgroundColor = UIColor(white: 0.96, alpha: 1)
addSubview(rootFlex)
...(略)...


// 主框架结构
rootFlex.flex.define { flex in
flex.addItem(mainScroll).grow(1).shrink(1).define { flex in
flex.addItem(mainContainer)
}
flex.addItem().height(60).backgroundColor(UIColor(white: 0.93, alpha: 1))
}

// 内容结构(此处只是改了rootFlex为mainContainer)
mainContainer.flex.paddingHorizontal(20).define { flex in
flex.addItem(titleLabel).marginTop(30).marginBottom(18)
flex.addItem(posterImgV).width(100%).aspectRatio(67/40).marginBottom(15).define { flex in
flex.addItem(posterTitleLabel).marginTop(16).marginLeft(20)
flex.addItem(posterSubtitleLabel).position(.absolute).left(20).bottom(16)
}
flex.addItem(cardInfoContainer).direction(.row).padding(20, 20, 20, 14)
.backgroundColor(.white).define { flex in
// 月卡标题, 剩余天数
flex.addItem().define { flex in
flex.addItem(cardTitleLabel)
flex.addItem(remainDaysLabel).marginTop(4)
}
// 占位
flex.addItem().grow(1).shrink(1)
// 骑行更划算
flex.addItem(tipsLabel)
// 查看按钮
flex.addItem(daysDetailBtn).marginLeft(14).paddingHorizontal(13)
}
flex.addItem(balanceView)
flex.addItem(bonusView)
flex.addItem(wxView)
flex.addItem(moreView1)
flex.addItem(moreView2)
}
}

layout代码也要更新:

func layout() {
rootFlex.flex.margin(pin.safeArea)
rootFlex.flex.layout()
mainContainer.flex.layout(mode: .adjustHeight)
}

override func layoutSubviews() {
super.layoutSubviews()
rootFlex.frame = bounds
layout() // 进行rootFlex布局计算
// 布局完成之后
mainScroll.contentSize = mainContainer.bounds.size
}

完美。

再迅速把bottomView补充完整,噢,那个锁图标我也给你画好了:

let lockIcon = UIImageView(image: UIImage(named: "bike_lock"))
let depositLabel = UILabel()
depositLabel.font = UIFont.systemFont(ofSize: 13)
depositLabel.textColor = UIColor(white: 0.5, alpha: 1)
depositLabel.text = "已交押金, 可享有平台各种会员服务"
let depositDetailBtn = UIButton(type: .system)
depositDetailBtn.titleLabel?.font = UIFont.systemFont(ofSize: 13)
depositDetailBtn.setTitleColor(.darkGray, for: .normal)
depositDetailBtn.setTitle("查看", for: .normal)
depositDetailBtn.layer.cornerRadius = 4
depositDetailBtn.layer.masksToBounds = true
depositDetailBtn.layer.borderColor = UIColor.darkGray.cgColor
depositDetailBtn.layer.borderWidth = 0.5


// 主框架结构
rootFlex.flex.define { flex in
flex.addItem(mainScroll).grow(1).shrink(1).define { flex in
flex.addItem(mainContainer)
}
// 底部固定view
flex.addItem().direction(.row).alignItems(.center).height(60)
.backgroundColor(UIColor(white: 0.93, alpha: 1)).define { flex in
flex.addItem(lockIcon).width(20).marginHorizontal(20).aspectRatio(of: lockIcon)
flex.addItem(depositLabel).marginRight(20).grow(1).shrink(1)
flex.addItem(depositDetailBtn).paddingHorizontal(10).marginRight(14)
}
}

咦?要不要试试跑iPhone X?谁怕谁呀?

再跑一下6Plus 9.0

哎哟,出事了……VC里补一句automaticallyAdjustsScrollViewInsets = false就好啦!

本篇完整代码在github


感觉写不下去了,还要一篇才能结束这个坑啊啊啊啊………

下一篇我们来写大家喜闻乐见的【UITableView之不等高cell布局】……

文中用到的所有素材均为学习使用,请不要用于商业用途,否则后果说不定很严重,自负啊!