483 lines
18 KiB
Swift
483 lines
18 KiB
Swift
//
|
||
// CreateBubbleSetupView.swift
|
||
// QuickLocation
|
||
//
|
||
|
||
import UIKit
|
||
import RxSwift
|
||
import RxCocoa
|
||
import SwiftDate
|
||
|
||
/// 设置页:时长 picker + 同页底部通知 sheet
|
||
final class CreateBubbleSetupView: UIView {
|
||
|
||
var disposeBag = DisposeBag()
|
||
let selectedHour = BehaviorRelay<Int>(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(239.0 / 750.0)
|
||
|
||
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[..<separatorRange.lowerBound])
|
||
attributed.addAttribute(
|
||
.foregroundColor,
|
||
value: UIColor(hexStr: "#16B3FF"),
|
||
range: NSRange(location: 0, length: (timePrefix as NSString).length)
|
||
)
|
||
}
|
||
|
||
return attributed
|
||
}
|
||
|
||
@objc private func tapMask(_ gesture: UITapGestureRecognizer) {
|
||
let point = gesture.location(in: sheetPanel)
|
||
if sheetPanel.bounds.contains(point) { return }
|
||
let megaphonePoint = gesture.location(in: megaphoneImage)
|
||
if megaphoneImage.bounds.contains(megaphonePoint) { return }
|
||
dismissNotifySheet()
|
||
}
|
||
|
||
func buildMessage(for hours: Int) -> 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<CGPoint>
|
||
) {
|
||
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)
|
||
}
|
||
}
|