544 lines
18 KiB
Swift
544 lines
18 KiB
Swift
//
|
||
// 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<Void>()
|
||
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")
|
||
}
|
||
}
|