// // CreateGroupView.swift // QuickLocation // // Created by 八条 on 2026/6/2. // import UIKit import RxSwift import RxCocoa class CreateGroupView: UIView { var disposeBag = DisposeBag() private let limitCount = 50 private let tagMaxLength = 10 var onConfirmAddTag: ((String) -> Void)? private func setupRx() { groupNameTF.rx.text.orEmpty .subscribe(onNext: { [weak self] text in guard let self = self else { return } if text.count > 10 { self.groupNameTF.text = String(text.prefix(10)) } }) .disposed(by: disposeBag) Observable.merge( groupContentTV.rx.didChange.asObservable(), groupContentTV.rx.text.map { _ in () }, groupContentTV.rx.methodInvoked(#selector(UITextView.paste(_:))).map { _ in () } ) .throttle(.milliseconds(100), scheduler: MainScheduler.instance) .subscribe(onNext: { [weak self] in guard let self = self else { return } let count = self.groupContentTV.text.count self.placeholderLab.isHidden = count != 0 if count > self.limitCount { self.groupContentTV.text = String(self.groupContentTV.text.prefix(self.limitCount)) self.groupContentTV.selectedRange = NSRange(location: self.limitCount, length: 0) return } }) .disposed(by: disposeBag) addTagTF.rx.text.orEmpty .subscribe(onNext: { [weak self] text in guard let self = self else { return } if text.count > self.tagMaxLength { self.addTagTF.text = String(text.prefix(self.tagMaxLength)) } let count = self.addTagTF.text?.count ?? 0 self.addTagCountLab.text = "\(count)/\(self.tagMaxLength)" }) .disposed(by: disposeBag) sheetCancelBtn.rx.tap .subscribe(onNext: { [weak self] in self?.dismissAddTagSheet() }) .disposed(by: disposeBag) sheetConfirmBtn.rx.tap .subscribe(onNext: { [weak self] in guard let self = self else { return } self.onConfirmAddTag?(self.addTagTF.text ?? "") }) .disposed(by: disposeBag) addTagClearBtn.rx.tap .subscribe(onNext: { [weak self] in self?.addTagTF.text = "" self?.addTagCountLab.text = "0/\(self?.tagMaxLength ?? 10)" }) .disposed(by: disposeBag) } private func setupUI() { addSubview(navBgView) addSubview(navView) addSubview(scrollView) addSubview(submitBtn) scrollView.addSubview(contentView) contentView.addSubview(iconCarousel) contentView.addSubview(typeNamePlaceholder) contentView.addSubview(groupNameTitleLab) contentView.addSubview(groupNameInputView) groupNameInputView.addSubview(groupNameTF) contentView.addSubview(groupContentTitleLab) contentView.addSubview(groupContentInputView) groupContentInputView.addSubview(groupContentTV) groupContentInputView.addSubview(placeholderLab) contentView.addSubview(tagTitleLab) contentView.addSubview(tagView) navBgView.layoutChain .edges(excludingEdge: .bottom) .heightToWidth(160/375) navView.layoutChain .edges(excludingEdge: .bottom) .height(kNaviHeight) submitBtn.layoutChain .bottom(kSafeBottomMargin + 24) .centerX() .edgesHorzontal(30) .height(56) scrollView.layoutChain .topToBottomOfView(navView) .edgesHorzontal() .bottomToTopOfView(submitBtn, offset: -12) contentView.layoutChain .edges() .widthToView(scrollView) iconCarousel.layoutChain .top(8) .edgesHorzontal() .height(130) typeNamePlaceholder.layoutChain .topToBottomOfView(iconCarousel, offset: 4) .centerX() groupNameTitleLab.layoutChain .topToBottomOfView(typeNamePlaceholder, offset: 24) .left(24) groupNameInputView.layoutChain .topToBottomOfView(groupNameTitleLab, offset: 10) .edgesHorzontal(20) .height(50) groupNameTF.layoutChain .edgesVertical() .left(16) .right(48) groupContentTitleLab.layoutChain .topToBottomOfView(groupNameInputView, offset: 18) .left(24) groupContentInputView.layoutChain .topToBottomOfView(groupContentTitleLab, offset: 10) .edgesHorzontal(20) .height(96) groupContentTV.layoutChain .top(8) .left(12) .right(48) .bottom(8) placeholderLab.layoutChain .topToView(groupContentTV, offset: 8) .leftToView(groupContentTV, offset: 5) tagTitleLab.layoutChain .topToBottomOfView(groupContentInputView, offset: 18) .left(24) tagView.layoutChain .topToBottomOfView(tagTitleLab, offset: 12) .edgesHorzontal(20) .height(28) .bottom(24) setupSheet() } func updateTagViewHeight() { tagView.collectionViewLayout.invalidateLayout() tagView.layoutIfNeeded() let height = max(28, tagView.collectionViewLayout.collectionViewContentSize.height) tagView.layoutChain.height(height) } func showAddTagSheet() { addTagTF.text = "" addTagCountLab.text = "0/\(tagMaxLength)" sheetOverlay.isHidden = false sheetOverlay.alpha = 0 sheetPanel.transform = CGAffineTransform(translationX: 0, y: 40) UIView.animate(withDuration: 0.28, delay: 0, options: .curveEaseOut) { self.sheetOverlay.alpha = 1 self.sheetPanel.transform = .identity } completion: { _ in self.addTagTF.becomeFirstResponder() } } func dismissAddTagSheet() { addTagTF.resignFirstResponder() UIView.animate(withDuration: 0.22, delay: 0, options: .curveEaseIn) { self.sheetOverlay.alpha = 0 self.sheetPanel.transform = CGAffineTransform(translationX: 0, y: 40) } completion: { _ in self.sheetOverlay.isHidden = true self.addTagTF.text = "" self.addTagCountLab.text = "0/\(self.tagMaxLength)" } } // MARK: - Sheet private func setupSheet() { sheetOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.45) sheetOverlay.isHidden = true addSubview(sheetOverlay) sheetOverlay.layoutChain.edges() sheetPanel.backgroundColor = .white sheetPanel.layer.cornerRadius = 24 sheetPanel.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] sheetPanel.clipsToBounds = true sheetOverlay.addSubview(sheetPanel) sheetPanel.layoutChain.edgesHorzontal().bottom() sheetHeaderView.backgroundColor = UIColor(hexStr: "#E8F7FF") sheetPanel.addSubview(sheetHeaderView) sheetHeaderView.layoutChain.edgesHorzontal().top().height(56) sheetTitleLab.text = "添加标签" sheetTitleLab.font = .systemFont(ofSize: 16, weight: .medium) sheetTitleLab.textColor = ThemeManager.shared.color.titleAuxColor sheetTitleLab.textAlignment = .center sheetHeaderView.addSubview(sheetTitleLab) sheetTitleLab.layoutChain.center() sheetAddIcon.backgroundColor = UIColor(hexStr: "#D6F0FF") sheetAddIcon.cornerRadius = 8 sheetAddIcon.contentMode = .center let plusConfig = UIImage.SymbolConfiguration(pointSize: 13, weight: .medium) sheetAddIcon.image = UIImage(systemName: "plus", withConfiguration: plusConfig) sheetAddIcon.tintColor = UIColor(hexStr: "#16B3FF") sheetPanel.addSubview(sheetAddIcon) sheetAddIcon.layoutChain .topToBottomOfView(sheetHeaderView, offset: 24) .left(20) .width(32) .height(32) addTagInputView.backgroundColor = UIColor(hexStr: "#F7F8FA") addTagInputView.cornerRadius = 10 sheetPanel.addSubview(addTagInputView) addTagClearBtn.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) addTagClearBtn.tintColor = UIColor(hexStr: "#FF5B5B") sheetPanel.addSubview(addTagClearBtn) addTagClearBtn.layoutChain .centerY(sheetAddIcon) .right(20) .width(22) .height(22) addTagInputView.layoutChain .centerY(sheetAddIcon) .leftToRightOfView(sheetAddIcon, offset: 10) .rightToLeftOfView(addTagClearBtn, offset: -10) .height(40) addTagInputView.addSubview(addTagTF) addTagInputView.addSubview(addTagCountLab) addTagCountLab.layoutChain .centerY() .right(12) addTagTF.layoutChain .edgesVertical() .left(12) .rightToLeftOfView(addTagCountLab, offset: -8) sheetPanel.addSubview(sheetCancelBtn) sheetPanel.addSubview(sheetConfirmBtn) sheetCancelBtn.layoutChain .topToBottomOfView(sheetAddIcon, offset: 28) .left(24) .height(44) .bottom(20 + kSafeBottomMargin) sheetConfirmBtn.layoutChain .leftToRightOfView(sheetCancelBtn, offset: 12) .right(24) .bottomToView(sheetCancelBtn) .heightToView(sheetCancelBtn) .widthToView(sheetCancelBtn) let tap = UITapGestureRecognizer(target: self, action: #selector(tapSheetMask)) sheetOverlay.addGestureRecognizer(tap) } @objc private func tapSheetMask(_ gesture: UITapGestureRecognizer) { let point = gesture.location(in: sheetPanel) if sheetPanel.bounds.contains(point) { return } dismissAddTagSheet() } // MARK: - Views lazy var navBgView: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "Common/navBar_bg_2") iv.contentMode = .scaleAspectFill return iv }() lazy var navView: BaseNavigationView = { let nav = BaseNavigationView(title: "创建圈子") return nav }() lazy var scrollView: UIScrollView = { let view = UIScrollView() view.backgroundColor = .clear view.keyboardDismissMode = .onDrag view.showsVerticalScrollIndicator = false return view }() lazy var contentView: UIView = { let view = UIView() view.backgroundColor = .clear return view }() lazy var iconCarousel: CreateGroupIconCarouselView = { let view = CreateGroupIconCarouselView() return view }() lazy var typeNamePlaceholder: UIView = { let view = UIView() view.backgroundColor = .white view.cornerRadius = 10 view.isUserInteractionEnabled = false view.addSubview(typeNameLab) typeNameLab.layoutChain .edgesVertical(10) .edgesHorzontal(31) return view }() lazy var typeNameLab: UILabel = { let label = UILabel() label.text = "未知" label.font = .systemFont(ofSize: 16, weight: .bold) label.textColor = UIColor(hexStr: "#3D3D3D") return label }() lazy var groupNameTitleLab: UILabel = { let label = UILabel() label.text = "圈子名称" label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = ThemeManager.shared.color.titleAuxColor return label }() lazy var groupNameInputView: UIView = { let view = UIView() view.backgroundColor = .white view.cornerRadius = 10 let icon = UIImageView() icon.image = UIImage(named: "Group/edit") icon.contentMode = .scaleAspectFit view.addSubview(icon) icon.layoutChain .right(14) .centerY() .width(22) .height(22) return view }() lazy var groupNameTF: UITextField = { let textField = UITextField(frame: .zero) textField.font = UIFont.systemFont(ofSize: 14, weight: .medium) textField.textColor = ThemeManager.shared.color.titleAuxColor textField.placeholderColor(placeholder: "请输入圈子名称", color: UIColor(hexStr: "#999999", alpha: 1.0)) return textField }() lazy var groupContentTitleLab: UILabel = { let label = UILabel() label.text = "圈子描述" label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = ThemeManager.shared.color.titleAuxColor return label }() lazy var groupContentInputView: UIView = { let view = UIView() view.backgroundColor = .white view.cornerRadius = 10 let icon = UIImageView() icon.image = UIImage(named: "Group/edit") icon.contentMode = .scaleAspectFit view.addSubview(icon) icon.layoutChain .right(14) .bottom(14) .width(22) .height(22) return view }() lazy var groupContentTV: UITextView = { let textView = UITextView() textView.backgroundColor = .clear textView.font = .systemFont(ofSize: 14, weight: .medium) textView.textColor = ThemeManager.shared.color.titleAuxColor textView.isScrollEnabled = true textView.textContainerInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0) return textView }() lazy var placeholderLab: UILabel = { let label = UILabel() label.text = "请输入.." label.textColor = ThemeManager.shared.color.contentColor label.font = .systemFont(ofSize: 14, weight: .medium) return label }() lazy var tagTitleLab: UILabel = { let label = UILabel() label.text = "选择标签" label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = ThemeManager.shared.color.titleAuxColor return label }() lazy var tagView: UICollectionView = { let layout = CreateGroupTagFlowLayout() layout.minimumInteritemSpacing = 8 layout.minimumLineSpacing = 8 layout.sectionInset = .zero let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) cv.backgroundColor = .clear cv.isScrollEnabled = false cv.register(TagCell.self) return cv }() lazy var submitBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("创建", for: .normal) btn.setTitleColor(.white, for: .normal) btn.titleLabel?.font = FontManager.boboBold(18) btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) btn.cornerRadius = 20 return btn }() private let sheetOverlay = UIView() private let sheetPanel = UIView() private let sheetHeaderView = UIView() private let sheetTitleLab = UILabel() private let sheetAddIcon = UIImageView() private let addTagInputView = UIView() lazy var addTagTF: UITextField = { let textField = UITextField(frame: .zero) textField.font = .systemFont(ofSize: 14, weight: .medium) textField.textColor = ThemeManager.shared.color.titleAuxColor textField.placeholderColor(placeholder: "请输入标签", color: UIColor(hexStr: "#999999", alpha: 1.0), font: .systemFont(ofSize: 14, weight: .medium)) return textField }() lazy var addTagCountLab: UILabel = { let label = UILabel() label.text = "0/10" label.font = .systemFont(ofSize: 12, weight: .regular) label.textColor = ThemeManager.shared.color.contentColor return label }() lazy var addTagClearBtn: UIButton = { let btn = UIButton(type: .custom) return btn }() lazy var sheetCancelBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("取消", for: .normal) btn.setTitleColor(UIColor(hexStr: "#16B3FF"), for: .normal) btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) btn.backgroundColor = .white btn.borderWidth = 1 btn.borderColor = UIColor(hexStr: "#16B3FF") btn.cornerRadius = 22 return btn }() lazy var sheetConfirmBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("确定", for: .normal) btn.setTitleColor(.white, for: .normal) btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) btn.setBackgroundImage(UIImage(named: "Common/gradient_bg"), for: .normal) btn.cornerRadius = 22 btn.clipsToBounds = true return btn }() override init(frame: CGRect) { super.init(frame: .zero) setupUI() setupRx() backgroundColor = UIColor(hexStr: "#FAFAFA") } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } // MARK: - Icon Carousel final class CreateGroupIconCarouselView: UIView { var onSelectIndex: ((Int) -> Void)? private let iconCount = 11 private let itemSize: CGFloat = 80 private var lastWidth: CGFloat = 0 var selectedIndex: Int = 1 { didSet { guard oldValue != selectedIndex else { return } onSelectIndex?(selectedIndex) } } override init(frame: CGRect) { super.init(frame: frame) clipsToBounds = false addSubview(collectionView) collectionView.layoutChain.edges() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() guard bounds.width > 0 else { return } if bounds.width != lastWidth { lastWidth = bounds.width let inset = max(0, (bounds.width - itemSize) / 2) flowLayout.sectionInset = UIEdgeInsets(top: 25, left: inset, bottom: 25, right: inset) collectionView.layoutIfNeeded() scrollToIndex(selectedIndex, animated: false) } applyTransforms() } private func scrollToIndex(_ index: Int, animated: Bool) { let item = max(0, min(iconCount - 1, index - 1)) collectionView.scrollToItem(at: IndexPath(item: item, section: 0), at: .centeredHorizontally, animated: animated) } private func nearestIndex() -> Int { let centerX = collectionView.contentOffset.x + collectionView.bounds.width / 2 var nearest = 0 var minDist = CGFloat.greatestFiniteMagnitude for i in 0.. Int { iconCount } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(for: indexPath) as CreateGroupIconCell cell.configure(UIImage(named: "GroupIcon/\(indexPath.item + 1)")) return cell } func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { applyTransforms() } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { selectedIndex = indexPath.item + 1 collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) } func scrollViewDidScroll(_ scrollView: UIScrollView) { applyTransforms() } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { let proposedCenter = targetContentOffset.pointee.x + scrollView.bounds.width / 2 var nearest = 0 var minDist = CGFloat.greatestFiniteMagnitude for i in 0.. [UICollectionViewLayoutAttributes]? { alignedAttrs.values.filter { $0.frame.intersects(rect) } } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { alignedAttrs[indexPath] ?? super.layoutAttributesForItem(at: indexPath) } override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { collectionView?.bounds.width != newBounds.width } private func leftAlign(_ attrs: [UICollectionViewLayoutAttributes]) { var x = sectionInset.left var y = sectionInset.top var lineHeight: CGFloat = 0 let maxX = (collectionView?.bounds.width ?? 0) - sectionInset.right for attr in attrs where attr.representedElementCategory == .cell { if x > sectionInset.left && x + attr.frame.width > maxX { x = sectionInset.left y += lineHeight + minimumLineSpacing lineHeight = 0 } attr.frame.origin = CGPoint(x: x, y: y) x += attr.frame.width + minimumInteritemSpacing lineHeight = max(lineHeight, attr.frame.height) } } } // MARK: - TagCell final class TagCell: UICollectionViewCell { static let reuseId = "TagCell" private let label: UILabel = { let l = UILabel() l.font = .systemFont(ofSize: 12, weight: .medium) l.textAlignment = .center return l }() private let plusView: UIImageView = { let view = UIImageView() let config = UIImage.SymbolConfiguration(pointSize: 13, weight: .medium) view.image = UIImage(systemName: "plus", withConfiguration: config) view.tintColor = .white view.contentMode = .center view.isHidden = true return view }() private var isTagSelected = false override init(frame: CGRect) { super.init(frame: frame) contentView.addSubview(label) contentView.addSubview(plusView) label.layoutChain.edges() plusView.layoutChain.edges() contentView.layer.cornerRadius = 8 contentView.backgroundColor = UIColor(hexStr: "#F0F2F5") updateStyle() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() plusView.isHidden = true label.isHidden = false label.text = nil } static func preferredSize(for item: String) -> CGSize { if item == CreateGroupViewModel.addTagToken { return CGSize(width: 28, height: 28) } let font = UIFont.systemFont(ofSize: 12, weight: .medium) let textW = ceil((item as NSString).size(withAttributes: [.font: font]).width) return CGSize(width: max(52, textW + 20), height: 28) } func configure(_ text: String, isSelected: Bool) { plusView.isHidden = true label.isHidden = false label.text = text isTagSelected = isSelected contentView.layer.cornerRadius = 8 updateStyle() } func configureAsAdd() { plusView.isHidden = false label.isHidden = true label.text = nil isTagSelected = false contentView.backgroundColor = UIColor(hexStr: "#16B3FF") contentView.layer.borderWidth = 0 contentView.layer.cornerRadius = 8 } func toggleSelection() { isTagSelected.toggle() updateStyle() } private func updateStyle() { if isTagSelected { label.textColor = UIColor(hexStr: "#16B3FF") contentView.backgroundColor = UIColor(hexStr: "#E3F6FF") contentView.layer.borderWidth = 1 contentView.layer.borderColor = UIColor(hexStr: "#16B3FF").cgColor } else { label.textColor = ThemeManager.shared.color.titleAuxColor contentView.backgroundColor = UIColor(hexStr: "#F0F2F5") contentView.layer.borderWidth = 0 } } }