// // CreateBubbleSetupView.swift // QuickLocation // import UIKit import RxSwift import RxCocoa import SwiftDate /// 设置页:时长 picker + 同页底部通知 sheet final class CreateBubbleSetupView: UIView { var disposeBag = DisposeBag() let selectedHour = BehaviorRelay(value: 1) var messageText: String = "" var onConfirmCreate: (() -> Void)? var onSheetVisibilityChanged: ((Bool) -> Void)? let heroView = BubbleHeroView() let confirmBtn = UIButton(type: .custom) let sheetConfirmBtn = UIButton(type: .custom) private let subtitleLab = UILabel() private let titleLab = UILabel() private let hourPicker = BubbleHourPickerView() private let sheetOverlay = UIView() private let sheetPanel = UIView() private let headerBgImage = UIImageView() private let megaphoneImage = UIImageView() private let sheetTitleLab = UILabel() private let messageView = UIView() private let messageLab = UILabel() private let previewAvatar = UIImageView() private let tipStack = UIStackView() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white setupUI() setupSheet() hourPicker.onHourChanged = { [weak self] hour in self?.selectedHour.accept(hour) } selectedHour.accept(1) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func showNotifySheet(message: String) { messageText = message messageLab.attributedText = notificationMessage(from: message) sheetOverlay.isHidden = false sheetOverlay.alpha = 0 sheetPanel.transform = CGAffineTransform(translationX: 0, y: 40) megaphoneImage.transform = CGAffineTransform(translationX: 0, y: 40) onSheetVisibilityChanged?(true) UIView.animate(withDuration: 0.28, delay: 0, options: .curveEaseOut) { self.sheetOverlay.alpha = 1 self.sheetPanel.transform = .identity self.megaphoneImage.transform = .identity } } func dismissNotifySheet() { onSheetVisibilityChanged?(false) UIView.animate(withDuration: 0.22, delay: 0, options: .curveEaseIn) { self.sheetOverlay.alpha = 0 self.sheetPanel.transform = CGAffineTransform(translationX: 0, y: 40) self.megaphoneImage.transform = CGAffineTransform(translationX: 0, y: 40) } completion: { _ in self.sheetOverlay.isHidden = true } } var isSheetVisible: Bool { !sheetOverlay.isHidden && sheetOverlay.alpha > 0.01 } private func setupUI() { addSubview(heroView) addSubview(titleLab) addSubview(subtitleLab) addSubview(hourPicker) addSubview(confirmBtn) heroView.showsOuterDecorBubbles = false heroView.layoutChain .top(33) .leading(30) .trailing(30) .heightToWidth(1) titleLab.text = "您会在气泡待多久?" titleLab.font = FontManager.boboBold(30) titleLab.textColor = UIColor(hexStr: "#293445") titleLab.textAlignment = .center titleLab.layoutChain.topToBottomOfView(heroView, offset: 25).centerX() subtitleLab.text = "设置气泡后会隐藏您的位置" subtitleLab.font = .systemFont(ofSize: 18, weight: .bold) subtitleLab.textColor = UIColor(hexStr: "#9CA3AF") subtitleLab.layoutChain.topToBottomOfView(titleLab, offset: 19).centerX() hourPicker.layoutChain .topToBottomOfView(subtitleLab, offset: 29) .edgesHorzontal() .height(96) confirmBtn.setTitle("确定", for: .normal) confirmBtn.setTitleColor(.white, for: .normal) confirmBtn.titleLabel?.font = FontManager.boboBold(18) confirmBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) confirmBtn.cornerRadius = 20 confirmBtn.layoutChain.left(30).right(30).bottom(34).height(56) } private func setupSheet() { sheetOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.45) sheetOverlay.isHidden = true addSubview(sheetOverlay) sheetOverlay.layoutChain.edges() sheetPanel.backgroundColor = .white sheetPanel.layer.cornerRadius = 40 sheetPanel.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] sheetPanel.clipsToBounds = true sheetOverlay.addSubview(sheetPanel) sheetPanel.layoutChain.edgesHorzontal().bottom() headerBgImage.image = UIImage(named: "Common/popup_header_bg") headerBgImage.contentMode = .scaleToFill sheetPanel.addSubview(headerBgImage) headerBgImage.layoutChain .top() .edgesHorzontal() .heightToWidth(120 / 375) megaphoneImage.image = UIImage(named: "Bubble/notification_megaphone") megaphoneImage.contentMode = .scaleAspectFit sheetOverlay.addSubview(megaphoneImage) megaphoneImage.layoutChain .left(34) .topToView(sheetPanel, offset: -44) .width(130) .height(90) sheetTitleLab.text = "通知您的圈子" sheetTitleLab.font = FontManager.boboBold(26) sheetTitleLab.textColor = UIColor(hexStr: "#173A5E") sheetTitleLab.textAlignment = .center sheetPanel.addSubview(sheetTitleLab) sheetTitleLab.layoutChain.top(52).centerX() messageView.backgroundColor = UIColor(hexStr: "#E1F4FF") messageView.layer.cornerRadius = 16 messageView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] sheetPanel.addSubview(messageView) messageView.addSubview(messageLab) messageLab.font = .systemFont(ofSize: 16, weight: .medium) messageLab.textColor = UIColor(hexStr: "#293445") messageLab.numberOfLines = 0 messageLab.lineBreakMode = .byWordWrapping messageLab.layoutChain.edgesHorzontal(16).edgesVertical(12) previewAvatar.contentMode = .scaleAspectFill previewAvatar.clipsToBounds = true previewAvatar.layer.cornerRadius = 20 previewAvatar.layer.borderWidth = 2 previewAvatar.layer.borderColor = UIColor.white.cgColor let avatar = AppContextManager.shared.avaterIcon previewAvatar.image = avatar.size.width > 0 ? avatar : UIImage(named: "Common/default_avatar") sheetPanel.addSubview(previewAvatar) messageView.layoutChain .topToBottomOfView(sheetTitleLab, offset: 42) .left(35) .right(75) previewAvatar.layoutChain .right(24) .topToView(messageView, offset: -20) .width(40) .height(40) tipStack.axis = .vertical tipStack.spacing = 16 sheetPanel.addSubview(tipStack) tipStack.layoutChain .topToBottomOfView(messageView, offset: 22) .edgesHorzontal(35) [ "您的圈子成员只会看到您在气泡中。", "当您在气泡中,将不会共享您的确切位置。" ].forEach { tipStack.addArrangedSubview(makeTipRow($0)) } sheetConfirmBtn.setTitle("确定", for: .normal) sheetConfirmBtn.setTitleColor(.white, for: .normal) sheetConfirmBtn.titleLabel?.font = FontManager.boboBold(18) sheetConfirmBtn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) sheetConfirmBtn.layer.cornerRadius = 20 sheetConfirmBtn.clipsToBounds = true sheetPanel.addSubview(sheetConfirmBtn) sheetConfirmBtn.layoutChain .topToBottomOfView(tipStack, offset: 32) .edgesHorzontal(30) .height(56) .bottom(12 + kSafeBottomMargin) let tap = UITapGestureRecognizer(target: self, action: #selector(tapMask)) sheetOverlay.addGestureRecognizer(tap) } private func makeTipRow(_ text: String) -> UIView { let row = UIView() let star = UIImageView(image: UIImage(named: "Bubble/notification_sparkle")) star.contentMode = .scaleAspectFit let lab = UILabel() lab.text = text lab.font = .systemFont(ofSize: 14, weight: .medium) lab.textColor = UIColor(hexStr: "#7C7F87") lab.numberOfLines = 0 row.addSubview(star) row.addSubview(lab) star.layoutChain.left().top(4).width(11).height(11) lab.layoutChain.leftToRightOfView(star, offset: 10).right().top().bottom() return row } private func notificationMessage(from message: String) -> NSAttributedString { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = 5 let attributed = NSMutableAttributedString( string: message, attributes: [ .font: UIFont.systemFont(ofSize: 16, weight: .medium), .foregroundColor: UIColor(hexStr: "#293445"), .paragraphStyle: paragraphStyle ] ) if let separatorRange = message.range(of: "之前") { let timePrefix = String(message[.. String { let endDate = Calendar.current.date(byAdding: .hour, value: hours, to: Date()) ?? Date() let timeStr = endDate.toFormat("HH:mm") return "\(timeStr)之前,我会一直在这个区域。如果需要我帮忙,请给我发消息。" } } /// 1–6 小时横向循环选择器,中心粉色「N小时」胶囊 private final class BubbleHourPickerView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { var onHourChanged: ((Int) -> Void)? private let hours = Array(1...6) private let loopCount = 200 private let itemWidth: CGFloat = 52 private let itemSpacing: CGFloat = 12 private let accent = UIColor(hexStr: "#FF5CB8") private var currentHour = 1 private var didInitialScroll = false private var lastLayoutWidth: CGFloat = 0 private lazy var layout: UICollectionViewFlowLayout = { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .horizontal layout.itemSize = CGSize(width: itemWidth, height: 70) layout.minimumLineSpacing = itemSpacing layout.minimumInteritemSpacing = 0 return layout }() private lazy var collectionView: UICollectionView = { let view = UICollectionView(frame: .zero, collectionViewLayout: layout) view.backgroundColor = .clear view.showsHorizontalScrollIndicator = false view.decelerationRate = .fast view.dataSource = self view.delegate = self view.register(HourCell.self, forCellWithReuseIdentifier: HourCell.reuseId) return view }() private let capsule = UIView() private let capsuleLab = UILabel() private let pointerLayer = CAShapeLayer() private let centerBar = UIView() override init(frame: CGRect) { super.init(frame: frame) clipsToBounds = false addSubview(collectionView) capsule.backgroundColor = accent capsule.layer.cornerRadius = 15 capsule.clipsToBounds = false capsule.isUserInteractionEnabled = false addSubview(capsule) capsuleLab.text = "1小时" capsuleLab.font = .systemFont(ofSize: 16, weight: .bold) capsuleLab.textColor = .white capsuleLab.textAlignment = .center capsule.addSubview(capsuleLab) pointerLayer.fillColor = accent.cgColor capsule.layer.addSublayer(pointerLayer) centerBar.backgroundColor = accent centerBar.layer.cornerRadius = 4 centerBar.isUserInteractionEnabled = false addSubview(centerBar) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() guard bounds.width > 0 else { return } if abs(bounds.width - lastLayoutWidth) > 0.5 { lastLayoutWidth = bounds.width let inset = max(0, (bounds.width - itemWidth) / 2) layout.sectionInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset) layout.invalidateLayout() } collectionView.frame = CGRect(x: 0, y: 18, width: bounds.width, height: bounds.height - 18) let capsuleSize = CGSize(width: 78, height: 30) capsule.frame = CGRect( x: (bounds.width - capsuleSize.width) / 2, y: 0, width: capsuleSize.width, height: capsuleSize.height ) capsuleLab.frame = capsule.bounds let pointerWidth: CGFloat = 10 let pointerHeight: CGFloat = 6 let path = UIBezierPath() let px = capsule.bounds.midX let py = capsule.bounds.maxY - 1 path.move(to: CGPoint(x: px - pointerWidth / 2, y: py)) path.addLine(to: CGPoint(x: px + pointerWidth / 2, y: py)) path.addLine(to: CGPoint(x: px, y: py + pointerHeight)) path.close() pointerLayer.path = path.cgPath centerBar.frame = CGRect(x: (bounds.width - 8) / 2, y: 46, width: 8, height: 36) if !didInitialScroll { didInitialScroll = true collectionView.layoutIfNeeded() scrollToHour(currentHour, animated: false) updateVisibleCells() } } private var itemSpan: CGFloat { itemWidth + itemSpacing } private var totalItems: Int { hours.count * loopCount } private func hour(at item: Int) -> Int { hours[((item % hours.count) + hours.count) % hours.count] } private func centeredItem() -> Int { let index = Int((collectionView.contentOffset.x / itemSpan).rounded()) return min(max(0, index), totalItems - 1) } private func scrollToHour(_ hour: Int, animated: Bool) { let hourIndex = hours.firstIndex(of: hour) ?? 0 let item = (loopCount / 2) * hours.count + hourIndex collectionView.setContentOffset(CGPoint(x: CGFloat(item) * itemSpan, y: 0), animated: animated) } private func commitCenteredHour() { let hour = self.hour(at: centeredItem()) guard hour != currentHour else { return } currentHour = hour capsuleLab.text = "\(hour)小时" onHourChanged?(hour) } private func updateVisibleCells() { for cell in collectionView.visibleCells { guard let hourCell = cell as? HourCell else { continue } let converted = collectionView.convert(hourCell.center, to: self) let distance = abs(converted.x - bounds.midX) let isCenter = distance < itemWidth * 0.45 hourCell.numberLab.alpha = isCenter ? 0 : max(0.28, 1 - distance / (itemWidth * 3.2)) hourCell.tick.alpha = isCenter ? 0 : max(0.35, 1 - distance / (itemWidth * 2.6)) } } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { totalItems } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HourCell.reuseId, for: indexPath) as! HourCell cell.numberLab.text = "\(hour(at: indexPath.item))" return cell } func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { updateVisibleCells() } func scrollViewDidScroll(_ scrollView: UIScrollView) { updateVisibleCells() commitCenteredHour() } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { commitCenteredHour() } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { commitCenteredHour() } } func scrollViewWillEndDragging( _ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer ) { let raw = targetContentOffset.pointee.x / itemSpan let index = CGFloat(Int(raw.rounded())) let clamped = min(max(0, index), CGFloat(totalItems - 1)) targetContentOffset.pointee.x = clamped * itemSpan } } private final class HourCell: UICollectionViewCell { static let reuseId = "HourCell" let numberLab = UILabel() let tick = UIView() override init(frame: CGRect) { super.init(frame: frame) numberLab.textAlignment = .center numberLab.font = .systemFont(ofSize: 22, weight: .medium) numberLab.textColor = UIColor(hexStr: "#B0B8C1") tick.backgroundColor = UIColor(hexStr: "#FFD2E8") tick.layer.cornerRadius = 5 contentView.addSubview(numberLab) contentView.addSubview(tick) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() numberLab.frame = CGRect(x: 0, y: 6, width: bounds.width, height: 28) tick.frame = CGRect(x: (bounds.width - 10) / 2, y: 40, width: 10, height: 16) } }