388 lines
13 KiB
Swift
388 lines
13 KiB
Swift
//
|
|
// 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<Void>()
|
|
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)
|
|
}
|
|
}
|
|
}
|