// // SOSPracticeView.swift // QuickLocation // import UIKit import SwiftyUserDefaults final class SOSPracticeView: UIView, UIScrollViewDelegate { private enum State { case guide case practiceIdle case practiceCountdown case success } private let guideView = UIView() private let practiceIdleView = UIView() private let practiceCountdownView = UIView() private let successView = UIView() private let guideScrollView = UIScrollView() private let pageControl = UIPageControl() private let practicePulseView = SOSPulseButtonView() private let practiceCountdownRing = SOSCountdownRingView() private let practiceSlider = SOSSlideCancelView(thumbImageName: "SOS/slider") private var state: State = .guide override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white setupUI() setupActions() setState(.guide) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func didMoveToWindow() { super.didMoveToWindow() if window == nil { practiceCountdownRing.stop() } } private func setupUI() { [guideView, practiceIdleView, practiceCountdownView, successView].forEach { addSubview($0) $0.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ $0.topAnchor.constraint(equalTo: topAnchor), $0.leadingAnchor.constraint(equalTo: leadingAnchor), $0.trailingAnchor.constraint(equalTo: trailingAnchor), $0.bottomAnchor.constraint(equalTo: bottomAnchor) ]) } setupGuideView() setupPracticeIdleView() setupPracticeCountdownView() setupSuccessView() } private func setupGuideView() { guideScrollView.isPagingEnabled = true guideScrollView.showsHorizontalScrollIndicator = false guideScrollView.bounces = false guideScrollView.delegate = self guideView.addSubview(guideScrollView) guideScrollView.translatesAutoresizingMaskIntoConstraints = false let pageStack = UIStackView() pageStack.axis = .horizontal pageStack.alignment = .fill pageStack.distribution = .fill pageStack.spacing = 0 guideScrollView.addSubview(pageStack) pageStack.translatesAutoresizingMaskIntoConstraints = false for index in 1...3 { let page = makeGuidePage(index: index) pageStack.addArrangedSubview(page) page.widthAnchor.constraint(equalTo: guideScrollView.frameLayoutGuide.widthAnchor).isActive = true } pageControl.numberOfPages = 3 pageControl.currentPage = 0 pageControl.currentPageIndicatorTintColor = UIColor(hexStr: "#4CC7F8") pageControl.pageIndicatorTintColor = UIColor(hexStr: "#D9D9D9") pageControl.isUserInteractionEnabled = false guideView.addSubview(pageControl) pageControl.translatesAutoresizingMaskIntoConstraints = false let startButton = UIButton(type: .custom) startButton.setTitle("开始练习", for: .normal) startButton.setTitleColor(.white, for: .normal) startButton.titleLabel?.font = FontManager.boboBold(18) startButton.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) startButton.cornerRadius = 20 startButton.addTarget(self, action: #selector(startPractice), for: .touchUpInside) guideView.addSubview(startButton) startButton.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ guideScrollView.topAnchor.constraint(equalTo: guideView.topAnchor), guideScrollView.leadingAnchor.constraint(equalTo: guideView.leadingAnchor), guideScrollView.trailingAnchor.constraint(equalTo: guideView.trailingAnchor), guideScrollView.bottomAnchor.constraint(equalTo: pageControl.topAnchor, constant: -8), pageStack.topAnchor.constraint(equalTo: guideScrollView.contentLayoutGuide.topAnchor), pageStack.leadingAnchor.constraint(equalTo: guideScrollView.contentLayoutGuide.leadingAnchor), pageStack.trailingAnchor.constraint(equalTo: guideScrollView.contentLayoutGuide.trailingAnchor), pageStack.bottomAnchor.constraint(equalTo: guideScrollView.contentLayoutGuide.bottomAnchor), pageStack.heightAnchor.constraint(equalTo: guideScrollView.frameLayoutGuide.heightAnchor), pageControl.centerXAnchor.constraint(equalTo: guideView.centerXAnchor), pageControl.bottomAnchor.constraint(equalTo: startButton.topAnchor, constant: -14), startButton.leadingAnchor.constraint(equalTo: guideView.leadingAnchor, constant: 30), startButton.trailingAnchor.constraint(equalTo: guideView.trailingAnchor, constant: -30), startButton.heightAnchor.constraint(equalToConstant: 56), startButton.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -20) ]) } private func makeGuidePage(index: Int) -> UIView { let page = UIView() let verticalScrollView = UIScrollView() verticalScrollView.showsVerticalScrollIndicator = false verticalScrollView.alwaysBounceVertical = false page.addSubview(verticalScrollView) verticalScrollView.translatesAutoresizingMaskIntoConstraints = false let contentView = UIView() verticalScrollView.addSubview(contentView) contentView.translatesAutoresizingMaskIntoConstraints = false let imageView = UIImageView(image: UIImage(named: "SOS/sos_guide_\(index)")) imageView.contentMode = .scaleAspectFit contentView.addSubview(imageView) imageView.translatesAutoresizingMaskIntoConstraints = false let bulletList = makeBulletList() contentView.addSubview(bulletList) bulletList.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ verticalScrollView.topAnchor.constraint(equalTo: page.topAnchor), verticalScrollView.leadingAnchor.constraint(equalTo: page.leadingAnchor), verticalScrollView.trailingAnchor.constraint(equalTo: page.trailingAnchor), verticalScrollView.bottomAnchor.constraint(equalTo: page.bottomAnchor), contentView.topAnchor.constraint(equalTo: verticalScrollView.contentLayoutGuide.topAnchor), contentView.leadingAnchor.constraint(equalTo: verticalScrollView.contentLayoutGuide.leadingAnchor), contentView.trailingAnchor.constraint(equalTo: verticalScrollView.contentLayoutGuide.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: verticalScrollView.contentLayoutGuide.bottomAnchor), contentView.widthAnchor.constraint(equalTo: verticalScrollView.frameLayoutGuide.widthAnchor), contentView.heightAnchor.constraint(greaterThanOrEqualTo: verticalScrollView.frameLayoutGuide.heightAnchor), imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 36), imageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), imageView.widthAnchor.constraint(equalToConstant: 240), imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor), bulletList.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 28), bulletList.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 45), bulletList.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -30), bulletList.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -16) ]) return page } private func makeBulletList() -> UIStackView { let stack = UIStackView() stack.axis = .vertical stack.alignment = .leading stack.distribution = .fillEqually stack.spacing = 10 ["夜间独自行走", "遇见突发危机事件", "遇到医疗紧急情况"].forEach { text in let label = UILabel() label.textColor = UIColor(hexStr: "#333333") label.font = .systemFont(ofSize: 16, weight: .regular) let attributed = NSMutableAttributedString(string: "✦ \(text)") attributed.addAttribute( .foregroundColor, value: UIColor(hexStr: "#11B8F4"), range: NSRange(location: 0, length: 1) ) label.attributedText = attributed stack.addArrangedSubview(label) } return stack } private func setupPracticeIdleView() { let contentView = makeVerticalContentView(in: practiceIdleView) let modeBadge = SOSPracticeModeBadge() contentView.addSubview(modeBadge) modeBadge.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(practicePulseView) practicePulseView.translatesAutoresizingMaskIntoConstraints = false let hintLabel = UILabel() hintLabel.textAlignment = .center hintLabel.font = .systemFont(ofSize: 20, weight: .regular) let hint = NSMutableAttributedString( string: "轻点感叹号发送SOS", attributes: [.foregroundColor: UIColor(hexStr: "#333333")] ) hint.addAttribute( .foregroundColor, value: UIColor(hexStr: "#FF383C"), range: NSRange(location: 7, length: 3) ) hintLabel.attributedText = hint contentView.addSubview(hintLabel) hintLabel.translatesAutoresizingMaskIntoConstraints = false let pulseWidth = practicePulseView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30) pulseWidth.priority = .defaultHigh NSLayoutConstraint.activate([ modeBadge.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 28), modeBadge.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), modeBadge.widthAnchor.constraint(equalToConstant: 139), modeBadge.heightAnchor.constraint(equalToConstant: 40), practicePulseView.topAnchor.constraint(equalTo: modeBadge.bottomAnchor, constant: 18), practicePulseView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), pulseWidth, practicePulseView.widthAnchor.constraint(lessThanOrEqualToConstant: 345), practicePulseView.heightAnchor.constraint(equalTo: practicePulseView.widthAnchor), hintLabel.topAnchor.constraint(equalTo: practicePulseView.bottomAnchor, constant: 16), hintLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), hintLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -20) ]) } private func setupPracticeCountdownView() { let contentView = makeVerticalContentView(in: practiceCountdownView) let modeBadge = SOSPracticeModeBadge() contentView.addSubview(modeBadge) modeBadge.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(practiceCountdownRing) practiceCountdownRing.translatesAutoresizingMaskIntoConstraints = false let titleLabel = UILabel() titleLabel.attributedText = Self.cancelTitle titleLabel.font = .systemFont(ofSize: 22, weight: .regular) titleLabel.textAlignment = .center contentView.addSubview(titleLabel) titleLabel.translatesAutoresizingMaskIntoConstraints = false let bodyLabel = UILabel() bodyLabel.text = "10秒后,会将你的SOS和位置发送到你的圈子和紧急联系人。" bodyLabel.textColor = UIColor(hexStr: "#273246") bodyLabel.font = .systemFont(ofSize: 15, weight: .regular) bodyLabel.textAlignment = .center bodyLabel.numberOfLines = 0 contentView.addSubview(bodyLabel) bodyLabel.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(practiceSlider) practiceSlider.translatesAutoresizingMaskIntoConstraints = false let ringWidth = practiceCountdownRing.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30) ringWidth.priority = .defaultHigh NSLayoutConstraint.activate([ modeBadge.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 28), modeBadge.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), modeBadge.widthAnchor.constraint(equalToConstant: 139), modeBadge.heightAnchor.constraint(equalToConstant: 40), practiceCountdownRing.topAnchor.constraint(equalTo: modeBadge.bottomAnchor, constant: 8), practiceCountdownRing.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), ringWidth, practiceCountdownRing.widthAnchor.constraint(lessThanOrEqualToConstant: 345), practiceCountdownRing.heightAnchor.constraint(equalTo: practiceCountdownRing.widthAnchor), titleLabel.topAnchor.constraint(equalTo: practiceCountdownRing.bottomAnchor, constant: 2), titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), bodyLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 24), bodyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 40), bodyLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -40), practiceSlider.topAnchor.constraint(greaterThanOrEqualTo: bodyLabel.bottomAnchor, constant: 28), practiceSlider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 30), practiceSlider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -30), practiceSlider.heightAnchor.constraint(equalToConstant: 56), practiceSlider.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -(kSafeBottomMargin + 20)) ]) } private func setupSuccessView() { let contentView = makeVerticalContentView(in: successView) let modeBadge = SOSPracticeModeBadge() contentView.addSubview(modeBadge) modeBadge.translatesAutoresizingMaskIntoConstraints = false let imageView = UIImageView(image: UIImage(named: "SOS/sos_practice_success")) imageView.contentMode = .scaleAspectFit contentView.addSubview(imageView) imageView.translatesAutoresizingMaskIntoConstraints = false let titleLabel = UILabel() titleLabel.textAlignment = .center titleLabel.font = .systemFont(ofSize: 22, weight: .regular) let title = NSMutableAttributedString( string: "求救成功", attributes: [.foregroundColor: UIColor(hexStr: "#333333")] ) title.addAttribute( .foregroundColor, value: UIColor(hexStr: "#FF383C"), range: NSRange(location: 2, length: 2) ) titleLabel.attributedText = title contentView.addSubview(titleLabel) titleLabel.translatesAutoresizingMaskIntoConstraints = false let subtitleLabel = UILabel() subtitleLabel.text = "太好了!您可以使用sos了" subtitleLabel.textColor = UIColor(hexStr: "#273246") subtitleLabel.font = .systemFont(ofSize: 16, weight: .regular) subtitleLabel.textAlignment = .center contentView.addSubview(subtitleLabel) subtitleLabel.translatesAutoresizingMaskIntoConstraints = false let doneButton = UIButton(type: .custom) doneButton.setTitle("我知道了", for: .normal) doneButton.setTitleColor(.white, for: .normal) doneButton.titleLabel?.font = FontManager.boboBold(18) doneButton.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) doneButton.cornerRadius = 20 doneButton.addTarget(self, action: #selector(finishPractice), for: .touchUpInside) contentView.addSubview(doneButton) doneButton.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ modeBadge.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 28), modeBadge.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), modeBadge.widthAnchor.constraint(equalToConstant: 139), modeBadge.heightAnchor.constraint(equalToConstant: 40), imageView.topAnchor.constraint(equalTo: modeBadge.bottomAnchor, constant: 72), imageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), imageView.widthAnchor.constraint(equalToConstant: 270), imageView.heightAnchor.constraint(equalToConstant: 190), titleLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 52), titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 34), subtitleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), doneButton.topAnchor.constraint(greaterThanOrEqualTo: subtitleLabel.bottomAnchor, constant: 30), doneButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 30), doneButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -30), doneButton.heightAnchor.constraint(equalToConstant: 56), doneButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -(kSafeBottomMargin + 20)) ]) } private func makeVerticalContentView(in container: UIView) -> UIView { let scrollView = UIScrollView() scrollView.showsVerticalScrollIndicator = false scrollView.alwaysBounceVertical = false container.addSubview(scrollView) scrollView.translatesAutoresizingMaskIntoConstraints = false let contentView = UIView() scrollView.addSubview(contentView) contentView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ scrollView.topAnchor.constraint(equalTo: container.topAnchor), scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor), contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), contentView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) ]) return contentView } private func setupActions() { practicePulseView.onTap = { [weak self] in guard let self, self.state == .practiceIdle else { return } self.setState(.practiceCountdown) } practiceCountdownRing.onCompleted = { [weak self] in guard let self, self.state == .practiceCountdown else { return } self.setState(.success) } practiceSlider.onCompleted = { [weak self] in guard let self, self.state == .practiceCountdown else { return } self.practiceCountdownRing.stop() self.setState(.practiceIdle) } } private func setState(_ state: State) { self.state = state guideView.isHidden = state != .guide practiceIdleView.isHidden = state != .practiceIdle practiceCountdownView.isHidden = state != .practiceCountdown successView.isHidden = state != .success switch state { case .guide: practiceCountdownRing.stop() practicePulseView.setAnimationEnabled(false) case .practiceIdle: practiceCountdownRing.stop() practiceSlider.configure(style: .practiceCountdown) practiceSlider.setInteractionEnabled(true) practicePulseView.setShowsHand(true) practicePulseView.setAnimationEnabled(true) case .practiceCountdown: practicePulseView.setAnimationEnabled(false) practiceSlider.configure(style: .practiceCountdown) practiceSlider.setInteractionEnabled(true) practiceCountdownRing.start(duration: 10) case .success: practiceCountdownRing.stop(reset: false) practicePulseView.setAnimationEnabled(false) } } @objc private func startPractice() { setState(.practiceIdle) } @objc private func finishPractice() { var completedUsers = Defaults[\.sosIsPracticeList] if !completedUsers.contains(AppContextManager.shared.userId) { completedUsers.append(AppContextManager.shared.userId) Defaults[\.sosIsPracticeList] = completedUsers } UIView.animate(withDuration: 0.25, animations: { self.alpha = 0 }, completion: { _ in self.isHidden = true self.alpha = 1 }) } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { guard scrollView === guideScrollView, scrollView.bounds.width > 0 else { return } pageControl.currentPage = Int(round(scrollView.contentOffset.x / scrollView.bounds.width)) } private static var cancelTitle: NSAttributedString { let title = NSMutableAttributedString( string: "滑动取消", attributes: [.foregroundColor: UIColor(hexStr: "#333333")] ) title.addAttribute( .foregroundColor, value: UIColor(hexStr: "#FF383C"), range: NSRange(location: 2, length: 2) ) return title } } private final class SOSPracticeModeBadge: UIView { private let gradientLayer = CAGradientLayer() override init(frame: CGRect) { super.init(frame: frame) gradientLayer.colors = [ UIColor(hexStr: "#DDF4FF").cgColor, UIColor(hexStr: "#52C5F4").cgColor, UIColor(hexStr: "#DDF4FF").cgColor ] gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) layer.addSublayer(gradientLayer) let label = UILabel() label.text = "练习模式" label.textColor = .white label.font = .systemFont(ofSize: 16, weight: .semibold) label.textAlignment = .center addSubview(label) label.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ label.topAnchor.constraint(equalTo: topAnchor), label.leadingAnchor.constraint(equalTo: leadingAnchor), label.trailingAnchor.constraint(equalTo: trailingAnchor), label.bottomAnchor.constraint(equalTo: bottomAnchor) ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = bounds } }