jsdw_ios/QuickLocation/Section/Group/Mailbox/MailboxDetailViewController...

393 lines
18 KiB
Swift

//
// MailboxDetailViewController.swift
// QuickLocation
//
import UIKit
import RxSwift
import Lottie
final class MailboxDetailViewController: UIViewController {
private var messages: [MailboxMessage]
private var currentIndex: Int
private var currentMessage: MailboxMessage
private let disposeBag = DisposeBag()
private let detailView = MailboxDetailView()
private var isLoading = false
var onMessageRead: ((MailboxMessage) -> Void)?
init(messages: [MailboxMessage], initialMessage: MailboxMessage) {
self.messages = messages
self.currentMessage = initialMessage
self.currentIndex = messages.firstIndex(where: { $0.id == initialMessage.id }) ?? 0
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
view = detailView
}
override func viewDidLoad() {
super.viewDidLoad()
detailView.closeButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
detailView.previousButton.addTarget(self, action: #selector(previousAction), for: .touchUpInside)
detailView.nextButton.addTarget(self, action: #selector(nextAction), for: .touchUpInside)
renderCurrentMessage()
}
private func renderCurrentMessage() {
detailView.configure(
message: currentMessage,
canGoPrevious: currentIndex > 0,
canGoNext: currentIndex + 1 < messages.count
)
}
private func loadMessage(at index: Int) {
guard messages.indices.contains(index), !isLoading else { return }
let cachedMessage = messages[index]
isLoading = true
detailView.setControlsEnabled(false)
DLToast.showLoading()
UserService.mailboxDetail(id: cachedMessage.id)
.subscribe(onNext: { [weak self] response in
guard let self else { return }
DLToast.dismiss()
self.isLoading = false
guard let responseMessage = response.data else {
self.detailView.setControlsEnabled(true)
DLToast.showError(text: response.message ?? "消息详情加载失败")
return
}
var detail = self.mergeDetail(responseMessage, fallback: cachedMessage)
detail.isRead = true
self.messages[index] = detail
self.currentIndex = index
self.currentMessage = detail
self.onMessageRead?(detail)
self.renderCurrentMessage()
}, onError: { [weak self] error in
DLToast.dismiss()
self?.isLoading = false
self?.detailView.setControlsEnabled(true)
DLToast.showError(text: error.localizedDescription)
})
.disposed(by: disposeBag)
}
private func mergeDetail(_ detail: MailboxMessage, fallback: MailboxMessage) -> MailboxMessage {
var merged = detail
if merged.id == 0 { merged.id = fallback.id }
if merged.title.isEmpty { merged.title = fallback.title }
if merged.content.isEmpty { merged.content = fallback.content }
if merged.senderId.isEmpty { merged.senderId = fallback.senderId }
if merged.senderIcon.isEmpty { merged.senderIcon = fallback.senderIcon }
if merged.modifyTime.isEmpty { merged.modifyTime = fallback.modifyTime }
if MailboxCategory(rawValue: merged.type) == nil { merged.type = fallback.type }
return merged
}
@objc private func closeAction() {
dismiss(animated: true)
}
@objc private func previousAction() {
loadMessage(at: currentIndex - 1)
}
@objc private func nextAction() {
loadMessage(at: currentIndex + 1)
}
}
private final class MailboxDetailView: UIView {
let previousButton = UIButton(type: .custom)
let closeButton = UIButton(type: .custom)
let nextButton = UIButton(type: .custom)
private let cardView = UIView()
private let cardBackgroundView = UIImageView()
private let birdImageView = UIImageView(image: UIImage(named: "Mailbox/detail_bird"))
private let typeLabel = UILabel()
private let dividerView = UIView()
private let titleLabel = UILabel()
private let scrollView = UIScrollView()
private let contentContainerView = UIView()
private let contentLabel = UILabel()
private let lottieView = LottieAnimationView()
private let quickMessageLabel = UILabel()
private var animationName: String?
private var contentBottomConstraint: NSLayoutConstraint?
private var cardHeightConstraint: NSLayoutConstraint?
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(message: MailboxMessage, canGoPrevious: Bool, canGoNext: Bool) {
typeLabel.text = message.category.detailTitle
titleLabel.text = message.title
configureContent(message: message)
previousButton.isEnabled = canGoPrevious
nextButton.isEnabled = canGoNext
updateButtonAppearance()
}
func setControlsEnabled(_ enabled: Bool) {
previousButton.isUserInteractionEnabled = enabled
nextButton.isUserInteractionEnabled = enabled
closeButton.isUserInteractionEnabled = enabled
previousButton.alpha = enabled ? (previousButton.isEnabled ? 1 : 0.35) : 0.35
nextButton.alpha = enabled ? (nextButton.isEnabled ? 1 : 0.35) : 0.35
closeButton.alpha = enabled ? 1 : 0.55
}
private func setupUI() {
backgroundColor = UIColor.black.withAlphaComponent(0.68)
cardView.layer.cornerRadius = 31
cardView.clipsToBounds = true
cardBackgroundView.image = UIImage(named: "Mailbox/detail_card")?.resizableImage(
withCapInsets: UIEdgeInsets(top: 70, left: 70, bottom: 70, right: 70),
resizingMode: .stretch
)
cardBackgroundView.contentMode = .scaleToFill
birdImageView.contentMode = .scaleAspectFit
typeLabel.font = .systemFont(ofSize: 18, weight: .medium)
typeLabel.textColor = UIColor(hexStr: "#14324E")
typeLabel.textAlignment = .center
dividerView.backgroundColor = UIColor(hexStr: "#80D8FF", alpha: 0.55)
titleLabel.font = .systemFont(ofSize: 18, weight: .medium)
titleLabel.textColor = UIColor(hexStr: "#333333")
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 0
scrollView.showsVerticalScrollIndicator = false
scrollView.alwaysBounceVertical = false
contentLabel.font = .systemFont(ofSize: 15)
contentLabel.textColor = UIColor(hexStr: "#858585")
contentLabel.textAlignment = .center
contentLabel.numberOfLines = 0
lottieView.contentMode = .scaleAspectFit
lottieView.loopMode = .loop
lottieView.backgroundBehavior = .pauseAndRestore
quickMessageLabel.font = .systemFont(ofSize: 15)
quickMessageLabel.textColor = UIColor(hexStr: "#52606D")
quickMessageLabel.textAlignment = .center
quickMessageLabel.numberOfLines = 0
quickMessageLabel.backgroundColor = UIColor(hexStr: "#DFF5FF")
quickMessageLabel.layer.cornerRadius = 9
quickMessageLabel.clipsToBounds = true
configureControlButton(previousButton, title: "上一条")
configureControlButton(nextButton, title: "下一条")
closeButton.setImage(UIImage(named: "Mailbox/detail_close"), for: .normal)
closeButton.accessibilityLabel = "关闭"
addSubview(cardView)
cardView.addSubview(cardBackgroundView)
addSubview(birdImageView)
cardView.addSubview(typeLabel)
cardView.addSubview(dividerView)
cardView.addSubview(titleLabel)
cardView.addSubview(scrollView)
scrollView.addSubview(contentContainerView)
contentContainerView.addSubview(contentLabel)
contentContainerView.addSubview(lottieView)
contentContainerView.addSubview(quickMessageLabel)
addSubview(previousButton)
addSubview(closeButton)
addSubview(nextButton)
[cardView, cardBackgroundView, birdImageView, typeLabel, dividerView, titleLabel, scrollView,
contentContainerView, contentLabel, lottieView, quickMessageLabel, previousButton, closeButton,
nextButton].forEach { $0.translatesAutoresizingMaskIntoConstraints = false }
let cardWidth = min(kScreenWidth - 48, 360)
let cardHeightConstraint = cardView.heightAnchor.constraint(equalToConstant: 245)
self.cardHeightConstraint = cardHeightConstraint
NSLayoutConstraint.activate([
cardView.centerXAnchor.constraint(equalTo: centerXAnchor),
cardView.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -32),
cardView.widthAnchor.constraint(equalToConstant: cardWidth),
cardHeightConstraint,
cardBackgroundView.topAnchor.constraint(equalTo: cardView.topAnchor),
cardBackgroundView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor),
cardBackgroundView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor),
cardBackgroundView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor),
birdImageView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 36),
birdImageView.bottomAnchor.constraint(equalTo: cardView.topAnchor, constant: 35),
birdImageView.widthAnchor.constraint(equalToConstant: 100),
birdImageView.heightAnchor.constraint(equalToConstant: 74),
typeLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 49),
typeLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 26),
typeLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -26),
dividerView.topAnchor.constraint(equalTo: typeLabel.bottomAnchor, constant: 15),
dividerView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 23),
dividerView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -23),
dividerView.heightAnchor.constraint(equalToConstant: 0.5),
titleLabel.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: 20),
titleLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 30),
titleLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -30),
scrollView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 13),
scrollView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 28),
scrollView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -28),
scrollView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -25),
contentContainerView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
contentContainerView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
contentContainerView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
contentContainerView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
contentContainerView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
contentLabel.topAnchor.constraint(equalTo: contentContainerView.topAnchor),
contentLabel.leadingAnchor.constraint(equalTo: contentContainerView.leadingAnchor),
contentLabel.trailingAnchor.constraint(equalTo: contentContainerView.trailingAnchor),
lottieView.topAnchor.constraint(equalTo: contentLabel.bottomAnchor, constant: 10),
lottieView.centerXAnchor.constraint(equalTo: contentContainerView.centerXAnchor),
lottieView.widthAnchor.constraint(equalToConstant: 116),
lottieView.heightAnchor.constraint(equalToConstant: 116),
quickMessageLabel.topAnchor.constraint(equalTo: contentLabel.bottomAnchor, constant: 14),
quickMessageLabel.centerXAnchor.constraint(equalTo: contentContainerView.centerXAnchor),
quickMessageLabel.leadingAnchor.constraint(greaterThanOrEqualTo: contentContainerView.leadingAnchor, constant: 20),
quickMessageLabel.trailingAnchor.constraint(lessThanOrEqualTo: contentContainerView.trailingAnchor, constant: -20),
quickMessageLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 40),
previousButton.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 20),
previousButton.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 34),
previousButton.heightAnchor.constraint(equalToConstant: 40),
nextButton.topAnchor.constraint(equalTo: previousButton.topAnchor),
nextButton.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -34),
nextButton.heightAnchor.constraint(equalTo: previousButton.heightAnchor),
closeButton.centerXAnchor.constraint(equalTo: centerXAnchor),
closeButton.centerYAnchor.constraint(equalTo: previousButton.centerYAnchor),
closeButton.widthAnchor.constraint(equalToConstant: 24),
closeButton.heightAnchor.constraint(equalToConstant: 24)
])
}
private func configureControlButton(_ button: UIButton, title: String) {
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.setTitleColor(UIColor.white.withAlphaComponent(0.45), for: .disabled)
button.titleLabel?.font = .systemFont(ofSize: 16)
}
private func configureContent(message: MailboxMessage) {
animationName = nil
lottieView.stop()
lottieView.animation = nil
lottieView.isHidden = true
quickMessageLabel.isHidden = true
contentLabel.text = message.content
cardHeightConstraint?.constant = message.content.count > 80 ? 300 : 245
guard message.category == .interaction,
let payload = parsePayload(message.content) else {
updateContentBottomAnchor(visibleView: contentLabel)
return
}
contentLabel.text = payload.prefix
switch payload.kind {
case let .animation(name):
cardHeightConstraint?.constant = 330
animationName = name
lottieView.isHidden = false
updateContentBottomAnchor(visibleView: lottieView)
EmojiPanelCell.loadAnimation(for: name) { [weak self] animation in
guard let self, self.animationName == name else { return }
guard let animation else {
self.showFallbackContent(message.content)
return
}
self.lottieView.animation = animation
self.lottieView.currentProgress = 0
self.lottieView.play()
}
case let .quickMessage(text):
cardHeightConstraint?.constant = 265
quickMessageLabel.text = " \(text) "
quickMessageLabel.isHidden = false
updateContentBottomAnchor(visibleView: quickMessageLabel)
}
}
private func updateContentBottomAnchor(visibleView: UIView) {
contentBottomConstraint?.isActive = false
contentBottomConstraint = visibleView.bottomAnchor.constraint(equalTo: contentContainerView.bottomAnchor)
contentBottomConstraint?.isActive = true
}
private func showFallbackContent(_ content: String) {
animationName = nil
lottieView.stop()
lottieView.animation = nil
lottieView.isHidden = true
quickMessageLabel.isHidden = true
contentLabel.text = content
updateContentBottomAnchor(visibleView: contentLabel)
}
private func parsePayload(_ content: String) -> MailboxPayload? {
guard let opening = content.firstIndex(of: "["),
let closing = content[opening...].firstIndex(of: "]"),
let lastOpening = content.lastIndex(of: "[") else {
return nil
}
let codeStart = content.index(after: opening)
let code = String(content[codeStart..<closing])
guard code.count > 1, let prefixCode = code.first else { return nil }
let value = String(code.dropFirst())
let prefix = String(content[..<lastOpening]).trimmingCharacters(in: .whitespacesAndNewlines)
switch prefixCode {
case "1":
guard Int(value) != nil else { return nil }
return MailboxPayload(prefix: prefix, kind: .animation(name: "normal_\(value)"))
case "2":
guard Int(value) != nil else { return nil }
return MailboxPayload(prefix: prefix, kind: .animation(name: "fun_\(value)"))
case "3":
guard let index = Int(value), QuickMessageView.messageList.indices.contains(index) else { return nil }
return MailboxPayload(prefix: prefix, kind: .quickMessage(QuickMessageView.messageList[index]))
default:
return nil
}
}
private func updateButtonAppearance() {
previousButton.alpha = previousButton.isEnabled ? 1 : 0.35
nextButton.alpha = nextButton.isEnabled ? 1 : 0.35
closeButton.alpha = 1
setControlsEnabled(true)
}
}
private struct MailboxPayload {
enum Kind {
case animation(name: String)
case quickMessage(String)
}
let prefix: String
let kind: Kind
}