Flex布局ScrollView

Flex布局ScrollView

920 发布: 2026/9/22 21:06 本文总阅读量

当视图含有滚动视图的时候使用flex布局注意

可滚动元素一定要加到contentView.rootView上否则不能触发sizeEvent

完整代码

AgentShare.swift


//
//  AgentShare.swift
//  YBLiveOne
//
//  Created by iyz on 2026/9/22.
//  Copyright © 2026 iOS. All rights reserved.
//

import FlexLayout
import PinLayout
import Photos
import SDWebImage
import SwiftyJSON
import UIKit

class AgentShare: BaseFlexView {
    private var titiL = UILabel()
    private var styleTitL = UILabel()
    private var scrollView = UIScrollView()
    private var contentView = BaseFlexView(.adjustWidth)
    private var shareTitL = UILabel()
    private let shareContentView = UIView()
    private let styleItemMarginLeft: CGFloat = 10
    private var styleContentWidth: CGFloat = 0
    private var styleItems: [AgentStyleItem] = []
    private var selectedThumb = ""
    private let shareItemHeight: CGFloat = 70
    private let shareColumnCount: CGFloat = 5

    override func initUI() {
        let tap = UITapGestureRecognizer(target: self, action: #selector(dissmiss))
        tap.delegate = self
        addGestureRecognizer(tap)

        titiL.text = i18n("分享赚钱")
        titiL.textColor = .hex("000")
        titiL.font = kFont(size: 18)

        styleTitL.text = i18n("分享样式")
        styleTitL.textColor = .hex("000")
        styleTitL.font = kFont(size: 15)

        shareTitL.text = i18n("分享至")
        shareTitL.textColor = .hex("000")
        shareTitL.font = kFont(size: 15)

        backgroundColor = .hex("000", 0.4)
        rootView.backgroundColor = .hex("#fff")
        rootView.layer.cornerRadius = 15
        rootView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]

        rootView.flex.paddingTop(20).paddingBottom(kSafeBotElement).define { flex in
            //
            flex.addItem().alignItems(.center).define { flex in
                flex.addItem(titiL)
            }
            //
            flex.addItem().height(44).justifyContent(.center).paddingHorizontal(15).marginTop(10).define { flex in
                flex.view?.backgroundColor = .hex("f6f6f6")
                flex.addItem(styleTitL)
            }
            //
            flex.addItem(scrollView).width(100%).height(AgentStyleItem.layoutHeight)
            //
            flex.addItem().height(44).justifyContent(.center).paddingHorizontal(15).define { flex in
                flex.view?.backgroundColor = .hex("f6f6f6")
                flex.addItem(shareTitL)
            }
            flex.addItem(shareContentView)
        }

        scrollView.addSubview(contentView)
        configureShareOptions()
    }

    @objc func configData() {
        ApiService.share.post(url: "Agent.getPosterList", parameter: nil) { [weak self] code, info, msg in
            guard code == 0 else {
                if code != 700, !msg.isEmpty {
                    kToastHud(msg)
                }
                return
            }
            self?.updateStyleItems(JSON(info).arrayValue)
        } fail: {}
    }

    private func updateStyleItems(_ lists: [JSON]) {
        contentView.subviews
            .filter { $0 !== contentView.rootView }
            .forEach { $0.removeFromSuperview() }
        styleContentWidth = 0
        styleItems = []
        if !lists.contains(where: { $0["thumb"].stringValue == selectedThumb }) {
            selectedThumb = lists.first?["thumb"].stringValue ?? ""
        }
        contentView.flex.direction(.row).define { flex in
            for list in lists {
                let agentItem = AgentStyleItem(.adjustHeight)
                agentItem.dataDic = list
                agentItem.isSelected = list["thumb"].stringValue == selectedThumb
                agentItem.onSelect = { [weak self] item in
                    self?.selectPoster(item)
                }
                flex.addItem(agentItem).width(AgentStyleItem.layoutWidth).marginLeft(styleItemMarginLeft)
                styleContentWidth += styleItemMarginLeft + AgentStyleItem.layoutWidth
                styleItems.append(agentItem)
            }
        }
        layout()
    }

    override func layout() {
        rootView.pin.bottom().left().width(100%)
        rootView.flex.layout(mode: .adjustHeight)
        let contentSize = CGSize(width: max(scrollView.bounds.width, styleContentWidth), height: AgentStyleItem.layoutHeight)
        contentView.pin.topLeft().size(contentSize)
        contentView.flex.layout(mode: .fitContainer)
        scrollView.contentSize = contentSize
    }

    private func configureShareOptions() {
        var platforms:[String] = Common.shareType.compactMap { $0.string }.filter { !$0.isEmpty }
        if !platforms.contains("save") {
            platforms.append("save")
        }
        let rowCount = max(1, Int(ceil(CGFloat(platforms.count) / shareColumnCount)))
        let itemWidthPercent = flexPercent(100 / shareColumnCount)
        shareContentView.flex.direction(.row).wrap(.wrap).paddingVertical(10).define { flex in
            for platform in platforms {
                let button = UIButton(type: .custom)
                button.setImage(UIImage(named: "liveShare_\(platform)"), for: .normal)
                button.accessibilityLabel = shareTitle(for: platform)
                button.addTarget(self, action: #selector(shareButtonTapped(_:)), for: .touchUpInside)
                button.accessibilityIdentifier = platform
                flex.addItem(button).width(itemWidthPercent).height(shareItemHeight)
            }
        }
        shareContentView.flex.height(CGFloat(rowCount) * shareItemHeight + 20)
    }

    private func flexPercent(_ value: CGFloat) -> FPercent {
        value%
    }

    private func selectPoster(_ item: AgentStyleItem) {
        selectedThumb = item.dataDic["thumb"].stringValue
        styleItems.forEach { $0.isSelected = $0 === item }
    }

    @objc private func shareButtonTapped(_ sender: UIButton) {
        guard let platform = sender.accessibilityIdentifier else { return }
        if platform == "save" {
            savePoster()
        } else {
            guard !selectedThumb.isEmpty else {
                kToastHud(i18n("请选择分享样式"))
                return
            }
            YBShareManager.sharePoster(selectedThumb, platform: platform)
        }
    }

    private func shareTitle(for platform: String) -> String {
        switch platform {
        case "wx": return i18n("微信")
        case "wchat": return i18n("朋友圈")
        case "qzone": return i18n("QQ空间")
        case "qq": return "QQ"
        case "facebook": return "Facebook"
        case "twitter": return i18n("推特")
        case "save": return i18n("保存图片")
        default: return platform
        }
    }

    private func savePoster() {
        guard let url = URL(string: selectedThumb) else {
            kToastHud(i18n("请选择分享样式"))
            return
        }
        URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
            guard self != nil, let data, let image = UIImage(data: data) else {
                DispatchQueue.main.async { kToastHud(i18n("保存图片失败")) }
                return
            }
            self?.save(image: image)
        }.resume()
    }

    private func save(image: UIImage) {
        let saveImage = { [weak self] in
            PHPhotoLibrary.shared().performChanges({
                PHAssetChangeRequest.creationRequestForAsset(from: image)
            }) { success, _ in
                DispatchQueue.main.async {
                    kToastHud(i18n(success ? "保存图片成功" : "保存图片失败"))
                    self?.setNeedsLayout()
                }
            }
        }
        switch PHPhotoLibrary.authorizationStatus() {
        case .authorized, .limited:
            saveImage()
        case .notDetermined:
            PHPhotoLibrary.requestAuthorization { status in
                if status == .authorized || status == .limited {
                    saveImage()
                } else {
                    DispatchQueue.main.async { kToastHud(i18n("没有相册权限")) }
                }
            }
        default:
            DispatchQueue.main.async { kToastHud(i18n("没有相册权限")) }
        }
    }

    @objc func dissmiss() {
        subviews.forEach { $0.removeFromSuperview() }
        removeFromSuperview()
    }
    override func layoutSubviews() {
        super.layoutSubviews()
    }
}

///
class AgentStyleItem: BaseFlexView {
    static let layoutWidth: CGFloat = 130
    static let imageAspectRatio: CGFloat = 0.667
    static let verticalPadding: CGFloat = 10
    static let buttonMarginTop: CGFloat = 10
    static let buttonHeight: CGFloat = 24
    static var imageHeight: CGFloat { layoutWidth / imageAspectRatio }
    static var layoutHeight: CGFloat {
        verticalPadding * 2 + imageHeight + buttonMarginTop + buttonHeight
    }

    private var imgIV = UIImageView(.fit)
    private var flagBtn = BaseButton(type: .custom)
    var onSelect: ((AgentStyleItem) -> Void)?

    var dataDic: JSON = [:] {
        didSet { configData() }
    }

    override var isSelected: Bool {
        didSet {
            flagBtn.isSelected = isSelected
        }
    }

    override func initUI() {
        addTarget(self, action: #selector(clickItem), for: .touchUpInside)
        rootView.isUserInteractionEnabled = false

        flagBtn.setTitleColor(.hex("#4998F5"), for: .normal)
        flagBtn.setTitle(i18n("使用"), for: .normal)
        flagBtn.jk_setBackgroundColor(.hex("fff"), for: .normal)

        flagBtn.setTitleColor(.hex("#fff"), for: .selected)
        flagBtn.setTitle(i18n("已选"), for: .selected)
        flagBtn.jk_setBackgroundColor(.hex("4998F5"), for: .selected)

        flagBtn.layer.borderColor = UIColor.hex("#4998F5").cgColor
        flagBtn.layer.borderWidth = 1
        flagBtn.titleLabel?.font = kFont(size: 12)
        flagBtn.contentEdgeInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)

        imgIV.layer.masksToBounds = true

        rootView.flex.direction(.column).alignItems(.center).paddingVertical(Self.verticalPadding).define { flex in
            flex.addItem(imgIV).width(Self.layoutWidth).height(Self.imageHeight)
            flex.addItem(flagBtn).marginTop(Self.buttonMarginTop).height(Self.buttonHeight).define { flex in
                flex.view?.layer.cornerRadius = 12
                flex.view?.layer.masksToBounds = true
            }
        }
    }

    func configData() {
        imgIV.sd_setImage(with: URL(string: dataDic["thumb"].stringValue))

        layout()
    }

    @objc func clickItem(it: AgentStyleItem) {
        onSelect?(it)
    }
}

///
class AgentShareItem: BaseFlexView {}

///
extension AgentShare: UIGestureRecognizerDelegate {
    // gesture delegate
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
        if let tView = touch.view {
            if tView.isDescendant(of: rootView) {
                return false
            }
        }
        return true
    }
}

AgentShare2.swift

//
//  AgentShare2.swift
//  YBLiveOne
//
//  使用 BaseFlexView2.sizeEvent 驱动 UIScrollView contentSize 的独立实现。
//

import FlexLayout
import PinLayout
import SwiftyJSON
import UIKit

final class AgentShare2: BaseFlexView2 {
    private let titleLabel = UILabel()
    private let styleTitleLabel = UILabel()
    private let shareTitleLabel = UILabel()
    private let scrollView = UIScrollView()
    private let contentView = BaseFlexView2(.adjustWidth)
    private let shareContentView = UIView()

    private let itemMarginLeft: CGFloat = 10
    private let shareItemHeight: CGFloat = 70
    private let shareColumnCount: CGFloat = 5
    private var styleItems: [AgentStyleItem] = []
    private var selectedThumb = ""

    override func initUI() {
        backgroundColor = .hex("000", 0.4)
        rootView.backgroundColor = .hex("fff")
        rootView.layer.cornerRadius = 15
        rootView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]

        configureLabels()
        configureScrollView()
        configureShareOptions()

        rootView.flex.paddingTop(20).paddingBottom(kSafeBotElement).define { flex in
            flex.addItem().alignItems(.center).define { flex in
                flex.addItem(titleLabel)
            }
            flex.addItem().height(44).justifyContent(.center).paddingHorizontal(15).marginTop(10).define { flex in
                flex.view?.backgroundColor = .hex("f6f6f6")
                flex.addItem(styleTitleLabel)
            }
            flex.addItem(scrollView).width(100%).height(AgentStyleItem.layoutHeight)
            flex.addItem().height(44).justifyContent(.center).paddingHorizontal(15).define { flex in
                flex.view?.backgroundColor = .hex("f6f6f6")
                flex.addItem(shareTitleLabel)
            }
            flex.addItem(shareContentView)
        }
    }

    @objc func configData() {
        ApiService.share.post(url: "Agent.getPosterList", parameter: nil) { [weak self] code, info, msg in
            guard code == 0 else {
                if code != 700, !msg.isEmpty {
                    kToastHud(msg)
                }
                return
            }
//            self?.updateStyleItems(JSON(info).arrayValue)
            var listsss = JSON(info).arrayValue
            listsss.append(contentsOf: JSON(info).arrayValue)
            listsss.append(contentsOf: JSON(info).arrayValue)
            listsss.append(contentsOf: JSON(info).arrayValue)
            self?.updateStyleItems(listsss)
        } fail: {}
    }

    /// 在接口数据返回后调用;滚动宽度由 contentView 的 sizeEvent 自动更新。
    func updateStyleItems(_ lists: [JSON]) {
        styleItems.forEach { $0.removeFromSuperview() }
        styleItems.removeAll()

        if !lists.contains(where: { $0["thumb"].stringValue == selectedThumb }) {
            selectedThumb = lists.first?["thumb"].stringValue ?? ""
        }

        // BaseFlexView2 实际参与 .adjustWidth 测量的是 rootView,
        // 海报项必须添加到 rootView.flex,sizeEvent 才能拿到内容总宽度。
        contentView.rootView.flex.direction(.row).define { flex in
            for data in lists {
                let item = AgentStyleItem(.adjustHeight)
                item.dataDic = data
                item.isSelected = data["thumb"].stringValue == selectedThumb
                item.onSelect = { [weak self] selectedItem in
                    self?.selectPoster(selectedItem)
                }
                flex.addItem(item)
                    .width(AgentStyleItem.layoutWidth)
                    .marginLeft(itemMarginLeft)
                styleItems.append(item)
            }
        }

        setNeedsLayout()
    }

    override func layout() {
        rootView.pin.bottom().left().width(100%)
        rootView.flex.layout(mode: .adjustHeight)

        // 【可不加此行】先给 contentView 一个可用高度;实际宽度随后由 .adjustWidth 计算。
        contentView.pin.topLeft()
            .width(scrollView.bounds.width)
            .height(AgentStyleItem.layoutHeight)
        contentView.layout()
    }

    private func configureLabels() {
        titleLabel.text = i18n("分享赚钱")
        titleLabel.textColor = .hex("000")
        titleLabel.font = kFont(size: 18)

        styleTitleLabel.text = i18n("分享样式")
        styleTitleLabel.textColor = .hex("000")
        styleTitleLabel.font = kFont(size: 15)

        shareTitleLabel.text = i18n("分享至")
        shareTitleLabel.textColor = .hex("000")
        shareTitleLabel.font = kFont(size: 15)
    }

    private func configureScrollView() {
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.addSubview(contentView)

        contentView.sizeEvent = { [weak self] contentWidth in
            guard let self else { return }
            let width = max(scrollView.bounds.width, contentWidth)
            contentView.pin.width(width).height(AgentStyleItem.layoutHeight)
            scrollView.contentSize = CGSize(width: width, height: AgentStyleItem.layoutHeight)
        }
    }

    private func configureShareOptions() {
        var platforms = Common.shareType.compactMap { $0.string }.filter { !$0.isEmpty }
        if !platforms.contains("save") {
            platforms.append("save")
        }

        let rowCount = max(1, Int(ceil(CGFloat(platforms.count) / shareColumnCount)))
        shareContentView.flex.direction(.row).wrap(.wrap).paddingVertical(10).define { flex in
            for platform in platforms {
                let button = UIButton(type: .custom)
                button.setImage(UIImage(named: "liveShare_\(platform)"), for: .normal)
                flex.addItem(button).width((100 / shareColumnCount)%).height(shareItemHeight)
            }
        }
        shareContentView.flex.height(CGFloat(rowCount) * shareItemHeight + 20)
    }

    private func selectPoster(_ item: AgentStyleItem) {
        selectedThumb = item.dataDic["thumb"].stringValue
        styleItems.forEach { $0.isSelected = $0 === item }
    }
}

BaseFlexView2.swift

//
//  BaseFlexView2.swift
//  YBLiveOne
//
//  独立的 Flex 容器:尺寸发生变化时通过 sizeEvent 回传最终宽度或高度。
//

import FlexLayout
import PinLayout
import UIKit

typealias FlexSizeEvent2 = (_ value: CGFloat) -> Void

class BaseFlexView2: UIControl {
    var layoutMode: Flex.LayoutMode
    var rootView = UIView()
    var sizeEvent: FlexSizeEvent2?

    private var lastSize: CGFloat = .zero

    init(_ mode: Flex.LayoutMode = .adjustHeight) {
        layoutMode = mode
        super.init(frame: .zero)
        commonInit()
    }

    required init?(coder: NSCoder) {
        layoutMode = .fitContainer
        super.init(coder: coder)
        commonInit()
    }

    override init(frame: CGRect) {
        layoutMode = .fitContainer
        super.init(frame: frame)
        commonInit()
    }

    private func commonInit() {
        initUI()
        addSubview(rootView)
    }

    func initUI() {}

    func layout() {
        switch layoutMode {
        case .adjustWidth:
            rootView.pin.top().left().height(100%)
            rootView.flex.layout(mode: .adjustWidth)
            notifySizeIfNeeded(rootView.bounds.width)
        case .adjustHeight:
            rootView.pin.top().right().width(100%)
            rootView.flex.layout(mode: .adjustHeight)
            notifySizeIfNeeded(rootView.bounds.height)
        default:
            rootView.pin.top().right().width(100%).height(100%)
            rootView.flex.layout(mode: layoutMode)
        }
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        layout()
    }

    override func sizeThatFits(_ size: CGSize) -> CGSize {
        layout()
        return rootView.bounds.size
    }

    private func notifySizeIfNeeded(_ value: CGFloat) {
        guard abs(lastSize - value) > .ulpOfOne else { return }
        lastSize = value
        sizeEvent?(value)
    }
}