// // SearchLocationView.swift // QuickLocation // // Created by 八条 on 2026/6/27. // import UIKit import RxSwift import RxCocoa import AVFoundation import Lottie class SearchLocationView: UIView { var disposeBag = DisposeBag() let onVideoComplete = PublishSubject() private var player: AVPlayer? private var timeObserver: Any? private func setupRx() { backBtn.rx.tap.subscribe(onNext: { _ in AppRouter.shared.popOrDismiss() }).disposed(by: disposeBag) phoneInputTF.rx.text .map { text -> String? in guard let text = text else { return nil } return String(text.prefix(11)) }.bind(to: phoneInputTF.rx.text) .disposed(by: disposeBag) phoneInputTF.rx.text.orEmpty.map { phone -> Bool in if phone.count == 11 { return true } else { return false } } .bind(to: searchBtn.rx.isEnabled) .disposed(by: disposeBag) phoneInputTF.rx.controlEvent(.editingDidEndOnExit) .subscribe(onNext: { [weak self] in guard let self = self else { return } self.phoneInputTF.resignFirstResponder() }) .disposed(by: disposeBag) } private func setupUI() { addSubview(navBgView) addSubview(navBarView) navBarView.addSubview(navTitleLabel) navBarView.addSubview(backBtn) addSubview(scrollView) addSubview(videoView) addSubview(searchProgressView) videoView.layoutChain.edges() searchProgressView.layoutChain .edgesHorzontal(15) .bottom(kSafeBottomMargin + 20) .height(221) navBgView.layoutChain .edges(excludingEdge: .bottom) .heightToWidth(160/375) navBarView.layoutChain .edges(excludingEdge: .bottom) .height(kNaviHeight) navTitleLabel.layoutChain .top(kStatusBarHeight + 12) .centerY(backBtn) .centerX() backBtn.layoutChain .centerY(navTitleLabel) .left(15) .width(24) .height(24) scrollView.layoutChain .topToBottomOfView(navBarView) .edges(excludingEdge: .top) } // MARK: - Marquee private func randomPhoneNumber() -> String { // 所有合法前3位号段 let prefixes = [ "134","135","136","137","138","139","147","150","151","152","157","158","159","178","182","183","184","187","188","198", "130","131","132","145","155","156","166","175","176","185","186","196", "133","149","153","173","177","180","181","189","191","199" ] // 随机选一个号段 let prefix = prefixes.randomElement()! // 随机生成后面8位数字 var suffix = "" for _ in 0..<8 { suffix.append("\(Int.random(in: 0...9))") } return prefix + suffix } private func setupMarquee() { let surnames = ["张", "李", "王", "陈", "刘", "杨", "赵", "黄", "周", "吴", "林", "何", "马", "胡", "郑", "梁", "谢", "宋", "唐", "韩"] let container = UIView() container.backgroundColor = .clear marqueeScrollView.addSubview(container) var prevView: UIView? for _ in 0..<10 { let iconIndex = Int.random(in: 1...15) let surname = surnames.randomElement() ?? "张" let maskedName = "\(surname)**" let phone = randomPhoneNumber() let maskedPhone = phone.prefix(3) + "******" + phone.suffix(2) let itemView = UIView() itemView.backgroundColor = UIColor(hexStr: "#EFF9FF") itemView.cornerRadius = 6 container.addSubview(itemView) let avatar = UIImageView(image: UIImage(named: "UserIcon/\(iconIndex)")) avatar.contentMode = .scaleAspectFill avatar.cornerRadius = 12 avatar.clipsToBounds = true itemView.addSubview(avatar) let label = UILabel() label.font = .systemFont(ofSize: 13, weight: .medium) let fullText = "\(maskedName) 定位到了 \(maskedPhone)" let attr = NSMutableAttributedString(string: fullText) attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: NSRange(fullText.range(of: maskedName)!, in: fullText)) if let phoneRange = fullText.range(of: maskedPhone) { attr.addAttribute(.foregroundColor, value: UIColor(hexStr: "#16B3FF"), range: NSRange(phoneRange, in: fullText)) } label.attributedText = attr itemView.addSubview(label) avatar.layoutChain .left(10).centerY() .width(24).height(24) label.layoutChain .leftToRightOfView(avatar, offset: 6) .centerY().right(10) itemView.layoutChain .centerY() .height(30) if let prev = prevView { itemView.layoutChain.leftToRightOfView(prev, offset: 20) } else { itemView.layoutChain.left() } prevView = itemView } if let last = prevView { container.layoutChain .edges().heightToView(marqueeScrollView) .rightToView(last) } } func startMarqueeAnimation() { marqueeScrollView.layer.removeAllAnimations() marqueeScrollView.contentOffset.x = 0 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in guard let self = self else { return } let contentW = self.marqueeScrollView.contentSize.width guard contentW > self.marqueeScrollView.bounds.width else { return } let duration = contentW / 100 UIView.animate(withDuration: duration, delay: 0, options: [.curveLinear, .repeat]) { self.marqueeScrollView.contentOffset.x = contentW - self.marqueeScrollView.bounds.width } } } override func willMove(toWindow newWindow: UIWindow?) { super.willMove(toWindow: newWindow) if newWindow != nil { startMarqueeAnimation() } } // MARK: - Views lazy var navBgView: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "Common/navBar_bg_2") iv.contentMode = .scaleAspectFill return iv }() lazy var navBarView: UIView = { let view = UIView() view.backgroundColor = .clear return view }() lazy var navTitleLabel: UILabel = { let label = UILabel() label.text = "找人助手" label.font = .systemFont(ofSize: 18, weight: .medium) label.textColor = ThemeManager.shared.color.titleAuxColor label.textAlignment = .center return label }() lazy var backBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "Common/back"), for: .normal) btn.extendEdgeInsets = UIEdgeInsets(top: 54, left: 15, bottom: 100, right: 100) return btn }() lazy var scrollView: UIScrollView = { let view = UIScrollView() view.backgroundColor = .clear view.showsVerticalScrollIndicator = false view.bounces = false let contentView = UIView() contentView.backgroundColor = .clear view.addSubview(contentView) contentView.layoutChain.edges().widthToView(view) let bgImgView = UIImageView() bgImgView.image = UIImage(named: "SearchLocation/bg_1") contentView.addSubview(bgImgView) bgImgView.layoutChain .top(15) .edgesHorzontal(56.5) .heightToWidth(626/524) contentView.addSubview(titleLab) titleLab.layoutChain .topToBottomOfView(bgImgView, offset: 0) .centerX() contentView.addSubview(marqueeScrollView) marqueeScrollView.layoutChain .topToBottomOfView(titleLab, offset: 10) .edgesHorzontal() .height(30) contentView.addSubview(searchInputView) searchInputView.layoutChain .topToBottomOfView(marqueeScrollView, offset: 20) .edgesHorzontal(15) .bottom(kSafeBottomMargin + 30) return view }() lazy var videoView: UIView = { let v = UIView() v.backgroundColor = .black v.isHidden = true return v }() func playVideo(completion: (() -> Void)? = nil) { guard let path = Bundle.main.path(forResource: "search_interlude", ofType: "mp4") ?? Bundle.main.path(forResource: "search_interlude", ofType: "mp4", inDirectory: "video") else { print("[video] file not found in bundle: \(Bundle.main.resourcePath ?? "")") return } videoView.isHidden = false videoView.layoutIfNeeded() let player = AVPlayer(url: URL(fileURLWithPath: path)) self.player = player let playerLayer = AVPlayerLayer(player: player) playerLayer.frame = videoView.bounds.isEmpty ? UIScreen.main.bounds : videoView.bounds playerLayer.videoGravity = .resizeAspectFill videoView.layer.addSublayer(playerLayer) // 进度跟踪 timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.1, preferredTimescale: 600), queue: .main) { [weak self] time in guard let self = self, let duration = self.player?.currentItem?.duration.seconds, duration > 0 else { return } self.updateVideoProgress(Float(time.seconds / duration)) } NotificationCenter.default.rx.notification(.AVPlayerItemDidPlayToEndTime, object: player.currentItem) .take(1) .subscribe(onNext: { _ in self.updateVideoProgress(1) completion?() self.onVideoComplete.onNext(()) }).disposed(by: disposeBag) player.play() DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in guard let self = self else { return } playerLayer.frame = self.videoView.bounds } } override func layoutSubviews() { super.layoutSubviews() if let playerLayer = videoView.layer.sublayers?.compactMap({ $0 as? AVPlayerLayer }).first { playerLayer.frame = videoView.bounds } } lazy var titleLab: UILabel = { let label = UILabel() label.text = "有 \(Int.random(in: 1000...10000)) 人正在使用此功能" label.font = .systemFont(ofSize: 16, weight: .medium) label.textColor = ThemeManager.shared.color.titleAuxColor label.textAlignment = .center return label }() lazy var marqueeScrollView: UIScrollView = { let sv = UIScrollView() sv.backgroundColor = .clear sv.showsHorizontalScrollIndicator = false sv.isScrollEnabled = false return sv }() lazy var searchInputView: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#EFF9FF") view.cornerRadius = 10 let titleLab = UILabel() titleLab.text = "TA在哪?输入号码就知道" titleLab.font = .systemFont(ofSize: 16, weight: .semibold) titleLab.textColor = ThemeManager.shared.color.titleAuxColor view.addSubview(titleLab) titleLab.layoutChain .top(18) .left(15) let inputView = UIView() inputView.backgroundColor = .white inputView.cornerRadius = 4 view.addSubview(inputView) inputView.layoutChain .topToBottomOfView(titleLab, offset: 20) .edgesHorzontal(15) .height(40) let contactsBtn = UIButton() contactsBtn.setTitle(" 通讯录导入", for: .normal) contactsBtn.setTitleColor(UIColor(hexStr: "#16B3FF"), for: .normal) contactsBtn.titleLabel?.font = .systemFont(ofSize: 12, weight: .regular) contactsBtn.setImage(UIImage(named: "SearchLocation/contacts"), for: .normal) contactsBtn.extendEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 15) contactsBtn.rx.tap.subscribe(onNext: { _ in //TODO: - 通讯录 }).disposed(by: disposeBag) inputView.addSubview(contactsBtn) contactsBtn.layoutChain .right(15) .width(90) .edgesVertical() contactsBtn.sizeToFit() inputView.addSubview(phoneInputTF) phoneInputTF.layoutChain .edgesVertical(10) .left(15) .rightToLeftOfView(contactsBtn, offset: -8) let tipsLab = UILabel() tipsLab.text = "我们不会存储和泄露你导入的通讯录隐私" tipsLab.font = .systemFont(ofSize: 12, weight: .regular) tipsLab.textColor = ThemeManager.shared.color.contentColor view.addSubview(tipsLab) tipsLab.layoutChain .topToBottomOfView(inputView, offset: 6) .right(15) view.addSubview(searchBtn) searchBtn.layoutChain .topToBottomOfView(tipsLab, offset: 35) .edgesHorzontal(52) .height(50) .bottom(30) return view }() lazy var phoneInputTF: UITextField = { let textField = UITextField() textField.font = .systemFont(ofSize: 14, weight: .medium) textField.placeholder = "请输入要查找的号码" textField.keyboardType = .numberPad textField.returnKeyType = .done return textField }() lazy var searchBtn: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("开始查找", for: .normal) btn.setTitleColor(.white, for: .normal) btn.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) btn.cornerRadius = 25 return btn }() lazy var searchProgressView: UIView = { let view = UIView() view.backgroundColor = .black.withAlphaComponent(0.8) view.cornerRadius = 16 view.borderWidth = 0.5 view.borderColor = .white view.isHidden = true view.addSubview(searchPhoneLottieView) searchPhoneLottieView.layoutChain .top(30) .left(15) .width(120) .heightToWidth(1) let steps = ["分析用户号码", "正在找 CGI 蜂窝参数", "SS7 信息交流", "号码已获授权", "处理完成"] var prevStepView: UIView? for (i, text) in steps.enumerated() { let stepView = UIView() view.addSubview(stepView) let icon = UIImageView(image: UIImage(named: "SearchLocation/done_off")) icon.contentMode = .scaleAspectFit stepView.addSubview(icon) progressIcons.append(icon) let label = UILabel() label.text = text label.font = .systemFont(ofSize: 16, weight: .medium) label.textColor = .white stepView.addSubview(label) icon.layoutChain .left().centerY() .width(18).height(18) label.layoutChain .leftToRightOfView(icon, offset: 8) .centerY().right() stepView.layoutChain .leftToRightOfView(searchPhoneLottieView, offset: 10) .right(10) .height(26) if let prev = prevStepView { stepView.layoutChain.topToBottomOfView(prev, offset: 4) } else { stepView.layoutChain.top(20) } prevStepView = stepView } // 进度条 + 百分比 progressBar.layer.cornerRadius = 4 progressBar.clipsToBounds = true progressBar.trackTintColor = .white.withAlphaComponent(0.2) progressBar.progressTintColor = UIColor(hexStr: "#16B3FF") view.addSubview(progressBar) progressLab.textColor = .white progressLab.font = .systemFont(ofSize: 12, weight: .medium) progressLab.textAlignment = .right view.addSubview(progressLab) progressBar.layoutChain .topToBottomOfView(prevStepView!, offset: 15) .left(20) .height(8) progressLab.layoutChain .leftToRightOfView(progressBar, offset: 8) .centerY(progressBar) .right(20) return view }() private var progressIcons: [UIImageView] = [] private lazy var progressBar: UIProgressView = { let p = UIProgressView() return p }() private lazy var progressLab: UILabel = { let l = UILabel() return l }() /// 根据视频进度更新步骤图标和进度条 (0~1) func updateVideoProgress(_ progress: Float) { let clamped = max(0, min(1, progress)) progressBar.progress = clamped progressLab.text = "\(Int(clamped * 100))%" let stepCount = progressIcons.count let stepProgress = 1.0 / Float(stepCount) for (i, icon) in progressIcons.enumerated() { let stepStart = stepProgress * Float(i) icon.image = UIImage(named: clamped >= stepStart + stepProgress * 0.5 ? "SearchLocation/done" : "SearchLocation/done_off") } } lazy var searchPhoneLottieView: LottieAnimationView = { let view = LottieAnimationView(name: "phone_search_interlude") view.loopMode = .loop return view }() override init(frame: CGRect) { super.init(frame: .zero) backgroundColor = .white setupUI() setupRx() setupMarquee() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } }