// // TaskAddViews.swift // suixinkan // import Kingfisher import SnapKit import UIKit /// 添加任务素材网格区。 final class TaskAddMediaSectionView: UIView { var onCloudImport: (() -> Void)? var onLocalImport: (() -> Void)? var onFileTap: ((TaskFileItem) -> Void)? var onDeleteFile: ((TaskFileItem) -> Void)? private var files: [TaskFileItem] = [] private var collectionHeightConstraint: Constraint? private let gridColumns = 3 private let gridSpacing: CGFloat = 8 private enum Section { case main } private enum Item: Hashable { case add; case file(String) } private lazy var collectionView: UICollectionView = { let layout = UICollectionViewCompositionalLayout { [weak self] _, environment in let columns = self?.gridColumns ?? 3 let spacing = self?.gridSpacing ?? 8 let availableWidth = environment.container.effectiveContentSize.width let totalSpacing = spacing * CGFloat(columns - 1) let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns)) let itemHeight = itemWidth / 1.77 let itemSize = NSCollectionLayoutSize( widthDimension: .absolute(itemWidth), heightDimension: .absolute(itemHeight) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemHeight) ) let group = NSCollectionLayoutGroup.horizontal( layoutSize: groupSize, repeatingSubitem: item, count: columns ) group.interItemSpacing = .fixed(spacing) let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = spacing return section } let view = UICollectionView(frame: .zero, collectionViewLayout: layout) view.backgroundColor = .clear view.isScrollEnabled = false return view }() private lazy var dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, item in switch item { case .add: let cell = collectionView.dequeueReusableCell( withReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier, for: indexPath ) as! TaskAddAddMediaCell return cell case .file(let id): let cell = collectionView.dequeueReusableCell( withReuseIdentifier: TaskAddMediaCell.reuseIdentifier, for: indexPath ) as! TaskAddMediaCell if let file = self?.files.first(where: { $0.id == id }) { cell.apply(file: file) cell.onDelete = { self?.onDeleteFile?(file) } } return cell } } private let titleLabel = UILabel() private let tipsLabel = UILabel() private let cloudButton = UIButton(type: .system) override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear titleLabel.text = "添加图片" titleLabel.font = .app(.bodyMedium) titleLabel.textColor = AppColor.textPrimary tipsLabel.text = "(点击图片或视频可添加备注)" tipsLabel.font = .app(.caption) tipsLabel.textColor = AppColor.textPrimary tipsLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) cloudButton.setTitle("云盘导入", for: .normal) cloudButton.setTitleColor(AppColor.primary, for: .normal) cloudButton.titleLabel?.font = .app(.caption) cloudButton.addTarget(self, action: #selector(cloudTapped), for: .touchUpInside) cloudButton.setContentCompressionResistancePriority(.required, for: .horizontal) collectionView.register(TaskAddMediaCell.self, forCellWithReuseIdentifier: TaskAddMediaCell.reuseIdentifier) collectionView.register(TaskAddAddMediaCell.self, forCellWithReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier) collectionView.delegate = self addSubview(titleLabel) addSubview(tipsLabel) addSubview(cloudButton) addSubview(collectionView) titleLabel.snp.makeConstraints { make in make.top.leading.equalToSuperview() } tipsLabel.snp.makeConstraints { make in make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xxs) make.centerY.equalTo(titleLabel) make.trailing.lessThanOrEqualTo(cloudButton.snp.leading).offset(-AppSpacing.xs) } cloudButton.snp.makeConstraints { make in make.trailing.equalToSuperview() make.centerY.equalTo(titleLabel) } collectionView.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm) make.leading.trailing.bottom.equalToSuperview() collectionHeightConstraint = make.height.equalTo(88).constraint } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(files: [TaskFileItem]) { self.files = files var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(files.map { Item.file($0.id) } + [.add]) dataSource.apply(snapshot, animatingDifferences: false) updateCollectionHeight(itemCount: files.count + 1) } override func layoutSubviews() { super.layoutSubviews() updateCollectionHeight(itemCount: files.count + 1) } private func updateCollectionHeight(itemCount: Int) { let availableWidth = collectionView.bounds.width guard availableWidth > 0 else { return } let itemWidth = max(0, (availableWidth - gridSpacing * CGFloat(gridColumns - 1)) / CGFloat(gridColumns)) let itemHeight = itemWidth / 1.77 let rows = max(1, Int(ceil(Double(itemCount) / Double(gridColumns)))) let height = itemHeight * CGFloat(rows) + gridSpacing * CGFloat(rows - 1) collectionHeightConstraint?.update(offset: max(88, height)) } @objc private func cloudTapped() { onCloudImport?() } } extension TaskAddMediaSectionView: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } switch item { case .add: onLocalImport?() case .file(let id): guard let file = files.first(where: { $0.id == id }) else { return } onFileTap?(file) } } } /// 圆形上传进度视图,对齐 Android 上传素材 cell 的进度表现。 final class TaskCircularProgressView: UIView { private let trackLayer = CAShapeLayer() private let progressLayer = CAShapeLayer() override init(frame: CGRect) { super.init(frame: frame) isHidden = true layer.addSublayer(trackLayer) layer.addSublayer(progressLayer) trackLayer.fillColor = UIColor.clear.cgColor trackLayer.strokeColor = UIColor.white.withAlphaComponent(0.35).cgColor trackLayer.lineWidth = 3 progressLayer.fillColor = UIColor.clear.cgColor progressLayer.strokeColor = UIColor.white.cgColor progressLayer.lineCap = .round progressLayer.lineWidth = 3 progressLayer.strokeEnd = 0 } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() let radius = min(bounds.width, bounds.height) / 2 - progressLayer.lineWidth / 2 let path = UIBezierPath( arcCenter: CGPoint(x: bounds.midX, y: bounds.midY), radius: radius, startAngle: -.pi / 2, endAngle: .pi * 1.5, clockwise: true ) trackLayer.frame = bounds progressLayer.frame = bounds trackLayer.path = path.cgPath progressLayer.path = path.cgPath } /// 设置 0...1 的上传进度。 func setProgress(_ progress: CGFloat) { progressLayer.strokeEnd = min(1, max(0, progress)) } } /// 素材缩略图 cell。 final class TaskAddMediaCell: UICollectionViewCell { static let reuseIdentifier = "TaskAddMediaCell" var onDelete: (() -> Void)? private let imageView = UIImageView() private let overlayView = UIView() private let progressRingView = TaskCircularProgressView() private let progressLabel = UILabel() private let deleteButton = UIButton(type: .system) private let playIconView = UIImageView() private let sourceBadgeLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = AppColor.inputBackground contentView.layer.cornerRadius = AppRadius.sm contentView.clipsToBounds = true imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.2) overlayView.isHidden = true progressLabel.font = .app(.captionMedium) progressLabel.textColor = .white progressLabel.textAlignment = .center deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) deleteButton.tintColor = .white deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) playIconView.image = UIImage(systemName: "play.circle.fill") playIconView.tintColor = .white playIconView.isHidden = true sourceBadgeLabel.font = .app(.caption) sourceBadgeLabel.textColor = .white sourceBadgeLabel.textAlignment = .center sourceBadgeLabel.backgroundColor = UIColor.black.withAlphaComponent(0.5) sourceBadgeLabel.layer.cornerRadius = AppRadius.xs sourceBadgeLabel.clipsToBounds = true sourceBadgeLabel.isHidden = true contentView.addSubview(imageView) contentView.addSubview(overlayView) contentView.addSubview(sourceBadgeLabel) contentView.addSubview(playIconView) contentView.addSubview(progressRingView) contentView.addSubview(progressLabel) contentView.addSubview(deleteButton) imageView.snp.makeConstraints { make in make.edges.equalToSuperview() } overlayView.snp.makeConstraints { make in make.edges.equalToSuperview() } sourceBadgeLabel.snp.makeConstraints { make in make.leading.bottom.equalToSuperview().inset(AppSpacing.xxs) make.height.equalTo(18) make.width.greaterThanOrEqualTo(34) } playIconView.snp.makeConstraints { make in make.center.equalToSuperview() make.width.height.equalTo(24) } progressRingView.snp.makeConstraints { make in make.centerX.equalToSuperview() make.centerY.equalToSuperview().offset(-10) make.width.height.equalTo(28) } progressLabel.snp.makeConstraints { make in make.top.equalTo(progressRingView.snp.bottom).offset(2) make.centerX.equalToSuperview() } deleteButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(AppSpacing.xxs) make.width.height.equalTo(20) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() onDelete = nil imageView.kf.cancelDownloadTask() imageView.image = nil progressRingView.setProgress(0) } func apply(file: TaskFileItem) { let urlString = file.previewURLString if let url = URL(string: urlString), !urlString.isEmpty { imageView.kf.setImage(with: url) } else { imageView.image = UIImage(systemName: "photo") imageView.tintColor = AppColor.textTertiary } playIconView.isHidden = file.fileType != 1 || file.isUploading deleteButton.isHidden = file.isUploading sourceBadgeLabel.isHidden = file.isUploading sourceBadgeLabel.text = file.sourceBadgeText if file.isUploading { overlayView.isHidden = false progressRingView.isHidden = false progressRingView.setProgress(CGFloat(file.uploadProgress) / 100) progressLabel.text = "\(file.uploadProgress)%" } else { overlayView.isHidden = true progressRingView.isHidden = true progressLabel.text = nil } } @objc private func deleteTapped() { onDelete?() } } /// 本地导入占位 cell。 final class TaskAddAddMediaCell: UICollectionViewCell { static let reuseIdentifier = "TaskAddAddMediaCell" private let iconView = UIImageView() private let titleLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = AppColor.inputBackground contentView.layer.cornerRadius = AppRadius.sm iconView.image = UIImage(systemName: "plus") iconView.tintColor = AppColor.textTabInactive titleLabel.text = "本地导入" titleLabel.font = .app(.caption) titleLabel.textColor = AppColor.textTabInactive contentView.addSubview(iconView) contentView.addSubview(titleLabel) iconView.snp.makeConstraints { make in make.centerX.equalToSuperview() make.centerY.equalToSuperview().offset(-8) make.width.height.equalTo(24) } titleLabel.snp.makeConstraints { make in make.top.equalTo(iconView.snp.bottom).offset(2) make.centerX.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 任务优先级选择区。 final class TaskPrioritySectionView: UIView { var onUrgentHourChange: ((Int) -> Void)? var onCustomHourTextChange: ((String) -> Void)? private let titleLabel = UILabel() private let noneButton = UIButton(type: .system) private let twoHourButton = UIButton(type: .system) private let customButton = UIButton(type: .system) private let customField = UITextField() private let customConfirmButton = UIButton(type: .system) private let optionRow = UIStackView() private let customRow = UIStackView() private let stackView = UIStackView() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear titleLabel.text = "优先级" titleLabel.font = .app(.bodyMedium) titleLabel.textColor = AppColor.textPrimary configureOptionButton(noneButton, title: "不加急") configureOptionButton(twoHourButton, title: "加急(2小时)") configureOptionButton(customButton, title: "自定义") noneButton.addTarget(self, action: #selector(noneTapped), for: .touchUpInside) twoHourButton.addTarget(self, action: #selector(twoHourTapped), for: .touchUpInside) customButton.addTarget(self, action: #selector(customTapped), for: .touchUpInside) customField.placeholder = "请输入小时数" customField.font = .app(.body) customField.textColor = AppColor.textPrimary customField.keyboardType = .numberPad customField.backgroundColor = .white customField.layer.cornerRadius = AppRadius.sm customField.layer.borderWidth = 1 customField.layer.borderColor = AppColor.border.cgColor customField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: AppSpacing.sm, height: 1)) customField.leftViewMode = .always customField.addTarget(self, action: #selector(customFieldChanged), for: .editingChanged) customConfirmButton.setTitle("确定", for: .normal) customConfirmButton.setTitleColor(.white, for: .normal) customConfirmButton.titleLabel?.font = .app(.bodyMedium) customConfirmButton.backgroundColor = AppColor.primary customConfirmButton.layer.cornerRadius = AppRadius.xs customConfirmButton.addTarget(self, action: #selector(customConfirmTapped), for: .touchUpInside) optionRow.addArrangedSubview(noneButton) optionRow.addArrangedSubview(twoHourButton) optionRow.addArrangedSubview(customButton) optionRow.axis = .horizontal optionRow.spacing = AppSpacing.xs optionRow.distribution = .fillEqually customRow.addArrangedSubview(customField) customRow.addArrangedSubview(customConfirmButton) customRow.axis = .horizontal customRow.spacing = AppSpacing.xs customRow.alignment = .fill customRow.isHidden = true stackView.addArrangedSubview(titleLabel) stackView.addArrangedSubview(optionRow) stackView.addArrangedSubview(customRow) stackView.axis = .vertical stackView.spacing = AppSpacing.xs addSubview(stackView) stackView.snp.makeConstraints { make in make.edges.equalToSuperview() } optionRow.snp.makeConstraints { make in make.height.equalTo(42) } customField.snp.makeConstraints { make in make.height.equalTo(46) } customConfirmButton.snp.makeConstraints { make in make.width.equalTo(72) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(urgentHour: Int) { let isCustom = urgentHour > 0 && urgentHour != 2 styleOption(noneButton, selected: urgentHour == 0) styleOption(twoHourButton, selected: urgentHour == 2) styleOption(customButton, selected: isCustom) customRow.isHidden = !isCustom if isCustom { customField.text = "\(urgentHour)" } else { customField.text = "" } } private func configureOptionButton(_ button: UIButton, title: String) { button.setTitle(title, for: .normal) button.titleLabel?.font = .app(.caption) button.layer.cornerRadius = AppRadius.xs button.clipsToBounds = true } private func styleOption(_ button: UIButton, selected: Bool) { button.backgroundColor = selected ? AppColor.primary : AppColor.inputBackground button.setTitleColor(selected ? .white : AppColor.textSecondary, for: .normal) } @objc private func noneTapped() { onUrgentHourChange?(0) } @objc private func twoHourTapped() { onUrgentHourChange?(2) } @objc private func customTapped() { onUrgentHourChange?(1) onCustomHourTextChange?("1") } @objc private func customFieldChanged() { onCustomHourTextChange?(customField.text ?? "") } @objc private func customConfirmTapped() { onCustomHourTextChange?(customField.text ?? "") } } /// 关联订单选择按钮。 final class TaskOrderSelectButton: UIControl { private let titleLabel = UILabel() private let arrowView = UIImageView(image: UIImage(systemName: "chevron.down")) override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white layer.cornerRadius = AppRadius.sm layer.borderWidth = 1 layer.borderColor = AppColor.border.cgColor titleLabel.font = .app(.bodyMedium) titleLabel.textColor = AppColor.textTabInactive titleLabel.text = "关联订单(可选)" arrowView.tintColor = AppColor.textTabInactive addSubview(titleLabel) addSubview(arrowView) titleLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(AppSpacing.sm) make.centerY.equalToSuperview() } arrowView.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(AppSpacing.sm) make.centerY.equalToSuperview() } snp.makeConstraints { make in make.height.equalTo(44) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(orderNumber: String?) { if let orderNumber, !orderNumber.isEmpty { titleLabel.text = orderNumber titleLabel.textColor = AppColor.textPrimary } else { titleLabel.text = "关联订单(可选)" titleLabel.textColor = AppColor.textTabInactive } } } /// 任务素材备注弹窗。 final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView { var onCancel: (() -> Void)? var onConfirm: (() -> Void)? private let containerView = UIView() private let titleLabel = UILabel() private let previewView = UIImageView() private let remarkTitleLabel = UILabel() private let textView = UITextView() private let placeholderLabel = UILabel() private let countLabel = UILabel() private let cancelButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system) private let buttonRow = UIStackView() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = AppColor.overlayScrim keyboardAvoidingContentView = containerView containerView.backgroundColor = .white containerView.layer.cornerRadius = AppRadius.lg titleLabel.text = "请填写剪辑备注" titleLabel.font = .app(.title) titleLabel.textColor = AppColor.textPrimary previewView.contentMode = .scaleAspectFill previewView.clipsToBounds = true previewView.layer.cornerRadius = AppRadius.lg previewView.backgroundColor = AppColor.inputBackground remarkTitleLabel.text = "备注信息" remarkTitleLabel.font = .app(.caption) remarkTitleLabel.textColor = AppColor.textPrimary textView.font = .app(.body) textView.textColor = AppColor.textPrimary textView.layer.borderWidth = 1 textView.layer.borderColor = AppColor.border.cgColor textView.layer.cornerRadius = AppRadius.sm textView.delegate = self textView.textContainerInset = UIEdgeInsets(top: AppSpacing.sm, left: AppSpacing.xs, bottom: AppSpacing.sm, right: AppSpacing.xs) placeholderLabel.text = "请输入备注(最多50字)" placeholderLabel.font = .app(.body) placeholderLabel.textColor = AppColor.textTertiary countLabel.font = .app(.caption) countLabel.textColor = AppColor.textTertiary countLabel.text = "0/50" cancelButton.setTitle("取消", for: .normal) cancelButton.setTitleColor(AppColor.textSecondary, for: .normal) cancelButton.backgroundColor = AppColor.inputBackground cancelButton.layer.cornerRadius = AppRadius.xs cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) confirmButton.setTitle("确定", for: .normal) confirmButton.setTitleColor(.white, for: .normal) confirmButton.backgroundColor = AppColor.primary confirmButton.layer.cornerRadius = AppRadius.xs confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) buttonRow.axis = .horizontal buttonRow.spacing = AppSpacing.xs buttonRow.alignment = .center buttonRow.distribution = .fill buttonRow.addArrangedSubview(cancelButton) buttonRow.addArrangedSubview(confirmButton) addSubview(containerView) containerView.addSubview(titleLabel) containerView.addSubview(previewView) containerView.addSubview(remarkTitleLabel) containerView.addSubview(countLabel) containerView.addSubview(textView) textView.addSubview(placeholderLabel) containerView.addSubview(buttonRow) containerView.snp.makeConstraints { make in make.centerX.equalToSuperview() keyboardAvoidingCenterYConstraint = make.centerY.equalToSuperview().constraint make.leading.trailing.equalToSuperview().inset(AppSpacing.lg) } titleLabel.snp.makeConstraints { make in make.top.leading.equalToSuperview().offset(AppSpacing.md) } previewView.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(previewView.snp.width) } remarkTitleLabel.snp.makeConstraints { make in make.top.equalTo(previewView.snp.bottom).offset(AppSpacing.md) make.leading.equalToSuperview().offset(AppSpacing.md) } countLabel.snp.makeConstraints { make in make.centerY.equalTo(remarkTitleLabel) make.trailing.equalToSuperview().inset(AppSpacing.md) } textView.snp.makeConstraints { make in make.top.equalTo(remarkTitleLabel.snp.bottom).offset(AppSpacing.xs) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(80) } placeholderLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(AppSpacing.sm) make.leading.equalToSuperview().offset(AppSpacing.sm + 3) make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.sm) } buttonRow.snp.makeConstraints { make in make.top.equalTo(textView.snp.bottom).offset(AppSpacing.md) make.trailing.bottom.equalToSuperview().inset(AppSpacing.md) } cancelButton.snp.makeConstraints { make in make.height.equalTo(36) make.width.equalTo(72) } confirmButton.snp.makeConstraints { make in make.height.equalTo(36) make.width.equalTo(72) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(file: TaskFileItem, text: String) { textView.text = text countLabel.text = "\(text.count)/50" placeholderLabel.isHidden = !text.isEmpty let urlString = file.previewURLString if let url = URL(string: urlString), !urlString.isEmpty { previewView.kf.setImage(with: url) } else { previewView.image = UIImage(systemName: "photo") previewView.tintColor = AppColor.textTertiary } } @objc private func cancelTapped() { onCancel?() } @objc private func confirmTapped() { onConfirm?() } /// 当前输入的备注文本。 var inputText: String { textView.text ?? "" } } extension TaskFileRemarkDialogView: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { if textView.text.count > 50 { textView.text = String(textView.text.prefix(50)) } placeholderLabel.isHidden = !textView.text.isEmpty countLabel.text = "\(textView.text.count)/50" } } /// 带边框的单行输入框。 final class TaskBorderTextField: UIView { let textField = UITextField() init(placeholder: String) { super.init(frame: .zero) backgroundColor = .white layer.cornerRadius = AppRadius.sm layer.borderWidth = 1 layer.borderColor = AppColor.border.cgColor textField.placeholder = placeholder textField.font = .app(.body) textField.textColor = AppColor.textPrimary addSubview(textField) textField.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)) } snp.makeConstraints { make in make.height.equalTo(44) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 带边框的多行输入框。 final class TaskBorderTextView: UIView { let textView = UITextView() init(placeholder: String) { super.init(frame: .zero) backgroundColor = .white layer.cornerRadius = AppRadius.sm layer.borderWidth = 1 layer.borderColor = AppColor.border.cgColor textView.font = .app(.body) textView.textColor = AppColor.textPrimary textView.text = placeholder textView.textColor = AppColor.textTertiary addSubview(textView) textView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(AppSpacing.xs) } snp.makeConstraints { make in make.height.equalTo(80) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 任务上传类型底部弹层,对齐 Android `AddFileBottomModalForTask`。 final class TaskUploadTypeSheetView: UIView { var onChooseImage: (() -> Void)? var onChooseVideo: (() -> Void)? var onCancel: (() -> Void)? private let dimmingView = UIView() private let panelView = UIView() private let titleLabel = UILabel() private let imageItem = TaskUploadTypeItemView( title: "上传图片", image: UIImage(named: "icon_cloud_storage_add_photo") ?? UIImage(systemName: "photo") ) private let videoItem = TaskUploadTypeItemView( title: "上传视频", image: UIImage(named: "icon_cloud_storage_add_video") ?? UIImage(systemName: "video") ) private let dividerView = UIView() private let cancelButton = UIButton(type: .system) private var panelBottomConstraint: Constraint? override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear dimmingView.backgroundColor = AppColor.overlayScrim dimmingView.alpha = 0 let tap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped)) dimmingView.addGestureRecognizer(tap) panelView.backgroundColor = .white panelView.layer.cornerRadius = AppRadius.sheetTop panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] panelView.clipsToBounds = true titleLabel.text = "选择上传类型" titleLabel.font = .app(.bodyMedium) titleLabel.textColor = AppColor.textPrimary titleLabel.textAlignment = .center imageItem.addTarget(self, action: #selector(imageTapped), for: .touchUpInside) videoItem.addTarget(self, action: #selector(videoTapped), for: .touchUpInside) dividerView.backgroundColor = AppColor.border cancelButton.setTitle("取消", for: .normal) cancelButton.titleLabel?.font = .app(.bodyMedium) cancelButton.setTitleColor(UIColor(hex: 0x4B5563), for: .normal) cancelButton.backgroundColor = AppColor.inputBackground cancelButton.layer.cornerRadius = AppRadius.lg cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) addSubview(dimmingView) addSubview(panelView) panelView.addSubview(titleLabel) panelView.addSubview(imageItem) panelView.addSubview(videoItem) panelView.addSubview(dividerView) panelView.addSubview(cancelButton) dimmingView.snp.makeConstraints { make in make.edges.equalToSuperview() } panelView.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() panelBottomConstraint = make.bottom.equalToSuperview().offset(260).constraint } titleLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) } imageItem.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(58) } videoItem.snp.makeConstraints { make in make.top.equalTo(imageItem.snp.bottom).offset(AppSpacing.sm) make.leading.trailing.equalTo(imageItem) make.height.equalTo(imageItem) } dividerView.snp.makeConstraints { make in make.top.equalTo(videoItem.snp.bottom).offset(AppSpacing.md) make.leading.trailing.equalToSuperview() make.height.equalTo(0.5) } cancelButton.snp.makeConstraints { make in make.top.equalTo(dividerView.snp.bottom).offset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) make.height.equalTo(48) make.bottom.equalTo(safeAreaLayoutGuide).inset(AppSpacing.md) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 展示底部弹层。 func show() { layoutIfNeeded() panelBottomConstraint?.update(offset: 0) UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut]) { self.dimmingView.alpha = 1 self.layoutIfNeeded() } } /// 关闭底部弹层。 func dismiss(completion: (() -> Void)? = nil) { panelBottomConstraint?.update(offset: panelView.bounds.height) UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseIn]) { self.dimmingView.alpha = 0 self.layoutIfNeeded() } completion: { _ in completion?() } } @objc private func imageTapped() { onChooseImage?() } @objc private func videoTapped() { onChooseVideo?() } @objc private func cancelTapped() { onCancel?() } } /// 上传类型弹层内的单个选项。 final class TaskUploadTypeItemView: UIControl { private let iconView = UIImageView() private let titleLabel = UILabel() init(title: String, image: UIImage?) { super.init(frame: .zero) backgroundColor = .white layer.cornerRadius = AppRadius.sm layer.borderWidth = 1 layer.borderColor = AppColor.inputBackground.cgColor iconView.image = image iconView.contentMode = .scaleAspectFit iconView.tintColor = AppColor.primary titleLabel.text = title titleLabel.font = .app(.bodyMedium) titleLabel.textColor = .black addSubview(iconView) addSubview(titleLabel) iconView.snp.makeConstraints { make in make.leading.equalToSuperview().offset(AppSpacing.md) make.centerY.equalToSuperview() make.width.height.equalTo(32) } titleLabel.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.sm) make.centerY.equalToSuperview() make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.md) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }