// // SearchLocationView.swift // QuickLocation // import UIKit import RxSwift import RxCocoa import AVFoundation import Lottie import Contacts import ContactsUI import URLNavigator class SearchLocationView: UIView { var disposeBag = DisposeBag() let onVideoComplete = PublishSubject() private var player: AVPlayer? private var timeObserver: Any? let headerView = SearchLocationHeaderView() private func setupRx() { 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 { $0.count == 11 } .bind(to: searchBtn.rx.isEnabled) .disposed(by: disposeBag) phoneInputTF.rx.controlEvent(.editingDidEndOnExit) .subscribe(onNext: { [weak self] in self?.phoneInputTF.resignFirstResponder() }) .disposed(by: disposeBag) } private func setupUI() { addSubview(navBgView) addSubview(navView) 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) navView.layoutChain .edges(excludingEdge: .bottom) .height(kNaviHeight) scrollView.layoutChain .topToBottomOfView(navView) .edges(excludingEdge: .top) } func startMarqueeAnimation() { headerView.startMarqueeAnimation() } override func willMove(toWindow newWindow: UIWindow?) { super.willMove(toWindow: newWindow) if newWindow != nil { startMarqueeAnimation() } } lazy var navBgView: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "Common/navBar_bg_2") iv.contentMode = .scaleAspectFill iv.isHidden = true return iv }() lazy var navView: BaseNavigationView = { BaseNavigationView(title: " ") }() 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) contentView.addSubview(headerView) headerView.layoutChain .top(12) .edgesHorzontal() .widthToView(contentView) contentView.addSubview(searchInputView) searchInputView.layoutChain .topToBottomOfView(headerView, 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 searchInputView: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#EFFAFF") view.cornerRadius = 40 let inputView = UIView() inputView.backgroundColor = .white inputView.cornerRadius = 14 view.addSubview(inputView) inputView.layoutChain .top(20) .edgesHorzontal(15) .height(44) let contactsBtn = UIButton() contactsBtn.setTitle("通讯录导入", for: .normal) contactsBtn.setTitleColor(UIColor(hexStr: "#16B3FF"), for: .normal) contactsBtn.titleLabel?.font = .systemFont(ofSize: 12, weight: .regular) contactsBtn.rx.tap.subscribe(onNext: { [weak self] in guard let self = self else { return } self.phoneInputTF.resignFirstResponder() let status = CNContactStore.authorizationStatus(for: .contacts) switch status { case .notDetermined: CNContactStore().requestAccess(for: .contacts) { granted, _ in DispatchQueue.main.async { if granted { self.presentContactPicker() } } } case .authorized: self.presentContactPicker() case .denied, .restricted: DLToast.show(text: "请在设置中开启通讯录权限") default: break } }).disposed(by: disposeBag) inputView.addSubview(contactsBtn) contactsBtn.layoutChain.edgesVertical().width(60).right(12) inputView.addSubview(phoneInputTF) phoneInputTF.layoutChain .edgesVertical(10) .left(15) .rightToLeftOfView(contactsBtn, offset: -8) let warningIcon = UIImageView(image: UIImage(named: "SearchLocation/warning")) warningIcon.contentMode = .scaleAspectFit view.addSubview(warningIcon) warningIcon.layoutChain .topToBottomOfView(inputView, offset: 10) .left(15) .width(14) .height(14) let tipsLab = UILabel() tipsLab.text = "我们不会存储和泄露你导入的通讯录隐私" tipsLab.font = .systemFont(ofSize: 12, weight: .regular) tipsLab.textColor = UIColor(hexStr: "#999999") view.addSubview(tipsLab) tipsLab.layoutChain .leftToRightOfView(warningIcon, offset: 4) .centerY(warningIcon) .right(15) view.addSubview(searchBtn) searchBtn.layoutChain .topToBottomOfView(warningIcon, offset: 24) .edgesHorzontal(20) .height(50) .bottom(24) 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 = FontManager.boboBold(16) btn.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal) btn.cornerRadius = 20 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 text in steps { 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 = { UIProgressView() }() private lazy var progressLab: UILabel = { UILabel() }() 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() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func presentContactPicker() { let picker = CNContactPickerViewController() picker.delegate = self picker.displayedPropertyKeys = [CNContactPhoneNumbersKey] UIViewController.topMost?.present(picker, animated: true) } } extension SearchLocationView: CNContactPickerDelegate { func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) { if let phone = contact.phoneNumbers.first?.value.stringValue { phoneInputTF.text = phone.filter { $0.isNumber } phoneInputTF.sendActions(for: .editingChanged) } } }