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布局】……

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

转载于:https://juejin.im/post/5b51aa495188251aba64843d

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值