本篇介绍一个简单界面的布局以及如何和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)
效果没什么不同,只不过我个人是习惯了:
- 先写布局方向(
direction
)或定位方式(position
) - 再写主轴对齐方式(
justifyContent
) - 再写侧轴对齐方式(
alignItems
) - 然后再写尺寸(
width、height
) - 然后是各种边距(
margin、padding
) - 接着写比例
aspectRatio
- 然后写
grow、shrink
- 最后调试需要就加背景色(
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布局】……
文中用到的所有素材均为学习使用,请不要用于商业用途,否则后果说不定很严重,自负啊!