jsdw_ios/QuickLocation/Section/Group/GroupItineraryView.swift

552 lines
21 KiB
Swift

//
// GroupItineraryView.swift
// QuickLocation
//
import UIKit
import RxSwift
import RxCocoa
import SwiftDate
/// Tab
final class GroupItineraryView: UIView {
var disposeBag = DisposeBag()
var onSwitchGroup: (() -> Void)?
var onDeleteSchedule: ((ScheduleModel) -> Void)?
var onSelectSchedule: ((ScheduleModel) -> Void)?
func updateGroupName(_ name: String) {
groupNameLab.text = name.isEmpty ? " " : name
}
func reloadSchedules(_ list: [ScheduleModel]) {
scheduleSections = Self.groupByDay(list)
tableView.reloadData()
tableView.refresh(status: .noMoreData, isEmpty: list.isEmpty)
}
private var scheduleSections: [(dayText: String, dateText: String, items: [ScheduleModel])] = []
private func setupUI() {
addSubview(headerRow)
headerRow.addSubview(groupIcon)
headerRow.addSubview(groupNameLab)
headerRow.addSubview(changeGroupBtn)
addSubview(tableView)
addSubview(createBtn)
headerRow.layoutChain
.top(8)
.edgesHorzontal(16)
.height(28)
groupIcon.layoutChain
.left()
.centerY()
.width(22)
.height(22)
changeGroupBtn.layoutChain
.right()
.centerY()
.height(28)
groupNameLab.layoutChain
.leftToRightOfView(groupIcon, offset: 8)
.centerY()
.rightToLeftOfView(changeGroupBtn, offset: -8, relation: .lessThanOrEqual)
tableView.layoutChain
.topToBottomOfView(headerRow, offset: 12)
.edgesHorzontal()
.bottom()
createBtn.layoutChain
.bottom(kSafeBottomMargin + 102)
.right(20)
.width(54)
.heightToWidth(1)
}
private func setupRx() {
createBtn.rx.tap.subscribe(onNext: { _ in
AppRouter.push(Route.createSchedule)
}).disposed(by: disposeBag)
changeGroupBtn.rx.tap
.subscribe(onNext: { [weak self] in self?.onSwitchGroup?() })
.disposed(by: disposeBag)
}
private static func groupByDay(_ list: [ScheduleModel]) -> [(dayText: String, dateText: String, items: [ScheduleModel])] {
let sorted = list.sorted { $0.timestamp > $1.timestamp }
var map: [String: [ScheduleModel]] = [:]
var order: [String] = []
var dayDateMap: [String: (dayText: String, dateText: String)] = [:]
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "zh_CN")
let calendar = Calendar.current
for item in sorted {
let date = Date(timeIntervalSince1970: TimeInterval(item.timestamp) / 1000)
fmt.dateFormat = "M月d日"
let dateText = fmt.string(from: date)
let dayText: String
if calendar.isDateInToday(date) {
dayText = "今天"
} else if calendar.isDateInYesterday(date) {
dayText = "昨天"
} else {
fmt.dateFormat = "EEEE"
dayText = fmt.string(from: date)
}
let key = "\(dayText) \(dateText)"
if map[key] == nil {
order.append(key)
dayDateMap[key] = (dayText, dateText)
map[key] = []
}
map[key]?.append(item)
}
return order.map { key in
let pair = dayDateMap[key] ?? ("", "")
return (pair.dayText, pair.dateText, map[key] ?? [])
}
}
lazy var headerRow: UIView = {
let v = UIView()
v.backgroundColor = .clear
return v
}()
lazy var groupIcon: UIImageView = {
let iv = UIImageView(image: UIImage(named: "Home/group_name_icon"))
iv.contentMode = .scaleAspectFit
return iv
}()
lazy var groupNameLab: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 16, weight: .bold)
label.textColor = UIColor(hexStr: "#353B4F")
label.text = " "
return label
}()
lazy var changeGroupBtn: UIButton = {
let btn = UIButton(type: .custom)
btn.setTitle("切换圈子 ", for: .normal)
btn.setTitleColor(UIColor(hexStr: "#353B4F"), for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
btn.setImage(UIImage(named: "Common/arrow_right"), for: .normal)
btn.semanticContentAttribute = .forceRightToLeft
return btn
}()
lazy var tableView: UITableView = {
let tv = UITableView(frame: .zero, style: .grouped)
tv.backgroundColor = UIColor(hexStr: "#FAFAFA")
tv.separatorStyle = .none
tv.showsVerticalScrollIndicator = false
tv.register(GroupItineraryCell.self)
tv.dataSource = self
tv.delegate = self
tv.estimatedRowHeight = 100
tv.rowHeight = UITableView.automaticDimension
tv.sectionHeaderHeight = 36
tv.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 97 + kSafeBottomMargin, right: 0)
if #available(iOS 15.0, *) {
tv.sectionHeaderTopPadding = 0
}
tv.dl.addEmptyDataSet { emptyDataSet in
emptyDataSet.emptyNoDataTitle = "这里怎么什么都没有~"
emptyDataSet.emptyNoDataDetail = ""
emptyDataSet.customButtonTitle = ""
emptyDataSet.backgroundColor = .clear
emptyDataSet.offsetY = -100
}
return tv
}()
lazy var createBtn: UIButton = {
let btn = UIButton()
btn.setImage(UIImage(named: "Schedule/create"), for: .normal)
return btn
}()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
setupUI()
setupRx()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - UITableView
extension GroupItineraryView: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
scheduleSections.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
scheduleSections[section].items.count
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let header = UIView()
header.backgroundColor = .clear
let sectionData = scheduleSections[section]
let label = UILabel()
let color = UIColor(hexStr: "#353B4F")
let attr = NSMutableAttributedString()
attr.append(NSAttributedString(
string: sectionData.dayText,
attributes: [
.font: UIFont.systemFont(ofSize: 16, weight: .bold),
.foregroundColor: color
]
))
if !sectionData.dateText.isEmpty {
attr.append(NSAttributedString(
string: " \(sectionData.dateText)",
attributes: [
.font: UIFont.systemFont(ofSize: 13, weight: .medium),
.foregroundColor: color
]
))
}
label.attributedText = attr
header.addSubview(label)
label.layoutChain.left(16).centerY()
return header
}
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
.leastNormalMagnitude
}
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
UIView()
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell: GroupItineraryCell = tableView.dequeueReusableCell(for: indexPath)
cell.configure(scheduleSections[indexPath.section].items[indexPath.row])
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
onSelectSchedule?(scheduleSections[indexPath.section].items[indexPath.row])
}
}
// MARK: - GroupItineraryCell
final class GroupItineraryCell: UITableViewCell {
func configure(_ model: ScheduleModel) {
avatarImg.image = model.userIcon
nameLab.text = model.nick_name.isEmpty ? "未命名用户" : model.nick_name
followIcon.isHidden = !model.is_follow
let points = Self.orderedPoints(model.points)
startLocationLab.text = Self.locationName(points.first)
endLocationLab.text = Self.locationName(points.last, maximumCharacters: 4)
durationLab.text = Self.durationText(from: points.first, to: points.last)
configureTags(model.groups.map(\.group_name).filter { !$0.isEmpty })
}
override init(style: CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .clear
contentView.addSubview(cardView)
cardView.addSubview(avatarImg)
cardView.addSubview(nameLab)
cardView.addSubview(followIcon)
cardView.addSubview(startMarkerLab)
cardView.addSubview(startLocationLab)
cardView.addSubview(routeLineView)
cardView.addSubview(durationLab)
cardView.addSubview(endMarkerLab)
cardView.addSubview(endLocationLab)
cardView.addSubview(tagScrollView)
tagScrollView.addSubview(tagStackView)
cardView.layoutChain
.edgesVertical(6)
.edgesHorzontal(15)
avatarImg.layoutChain
.top(12)
.left(14)
.width(28)
.height(28)
nameLab.layoutChain
.leftToRightOfView(avatarImg, offset: 8)
.centerY(avatarImg)
followIcon.layoutChain
.leftToRightOfView(nameLab, offset: 7)
.centerY(nameLab)
.width(20)
.height(20)
followIcon.translatesAutoresizingMaskIntoConstraints = false
followIcon.trailingAnchor.constraint(lessThanOrEqualTo: cardView.trailingAnchor, constant: -14).isActive = true
startMarkerLab.translatesAutoresizingMaskIntoConstraints = false
startLocationLab.translatesAutoresizingMaskIntoConstraints = false
routeLineView.translatesAutoresizingMaskIntoConstraints = false
durationLab.translatesAutoresizingMaskIntoConstraints = false
endMarkerLab.translatesAutoresizingMaskIntoConstraints = false
endLocationLab.translatesAutoresizingMaskIntoConstraints = false
tagScrollView.translatesAutoresizingMaskIntoConstraints = false
tagStackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
startMarkerLab.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 14),
startMarkerLab.topAnchor.constraint(equalTo: avatarImg.bottomAnchor, constant: 10),
startMarkerLab.widthAnchor.constraint(equalToConstant: 20),
startMarkerLab.heightAnchor.constraint(equalToConstant: 20),
startLocationLab.leadingAnchor.constraint(equalTo: startMarkerLab.trailingAnchor, constant: 7),
startLocationLab.centerYAnchor.constraint(equalTo: startMarkerLab.centerYAnchor),
startLocationLab.widthAnchor.constraint(lessThanOrEqualToConstant: 82),
endLocationLab.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -14),
endLocationLab.centerYAnchor.constraint(equalTo: startMarkerLab.centerYAnchor),
endLocationLab.widthAnchor.constraint(lessThanOrEqualToConstant: 82),
endMarkerLab.trailingAnchor.constraint(equalTo: endLocationLab.leadingAnchor, constant: -7),
endMarkerLab.centerYAnchor.constraint(equalTo: startMarkerLab.centerYAnchor),
endMarkerLab.widthAnchor.constraint(equalToConstant: 20),
endMarkerLab.heightAnchor.constraint(equalToConstant: 20),
routeLineView.leadingAnchor.constraint(equalTo: startLocationLab.trailingAnchor, constant: 8),
routeLineView.trailingAnchor.constraint(equalTo: endMarkerLab.leadingAnchor, constant: -8),
routeLineView.centerYAnchor.constraint(equalTo: startMarkerLab.centerYAnchor, constant: 4),
routeLineView.heightAnchor.constraint(equalToConstant: 1),
routeLineView.widthAnchor.constraint(greaterThanOrEqualToConstant: 28),
durationLab.centerXAnchor.constraint(equalTo: routeLineView.centerXAnchor),
durationLab.bottomAnchor.constraint(equalTo: routeLineView.topAnchor, constant: -3),
durationLab.widthAnchor.constraint(lessThanOrEqualTo: routeLineView.widthAnchor, constant: 24),
tagScrollView.topAnchor.constraint(equalTo: startMarkerLab.bottomAnchor, constant: 8),
tagScrollView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 90),
tagScrollView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -14),
tagScrollView.heightAnchor.constraint(equalToConstant: 18),
tagScrollView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -10),
tagStackView.leadingAnchor.constraint(equalTo: tagScrollView.contentLayoutGuide.leadingAnchor),
tagStackView.trailingAnchor.constraint(equalTo: tagScrollView.contentLayoutGuide.trailingAnchor),
tagStackView.topAnchor.constraint(equalTo: tagScrollView.contentLayoutGuide.topAnchor),
tagStackView.bottomAnchor.constraint(equalTo: tagScrollView.contentLayoutGuide.bottomAnchor),
tagStackView.heightAnchor.constraint(equalTo: tagScrollView.frameLayoutGuide.heightAnchor),
tagStackView.widthAnchor.constraint(greaterThanOrEqualTo: tagScrollView.frameLayoutGuide.widthAnchor)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private lazy var cardView: UIView = {
let v = UIView()
v.backgroundColor = .white
v.cornerRadius = 16
return v
}()
private lazy var avatarImg: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.cornerRadius = 10
iv.clipsToBounds = true
return iv
}()
private lazy var nameLab: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 15, weight: .bold)
label.textColor = UIColor(hexStr: "#353B4F")
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return label
}()
private lazy var followIcon: UIImageView = {
let view = UIImageView(image: Self.followBadgeImage())
view.contentMode = .scaleAspectFit
return view
}()
private static func followBadgeImage() -> UIImage? {
let size = CGSize(width: 22, height: 18)
UIGraphicsBeginImageContextWithOptions(size, false, 0)
defer { UIGraphicsEndImageContext() }
let configuration = UIImage.SymbolConfiguration(pointSize: 13, weight: .bold)
UIImage(systemName: "heart.fill", withConfiguration: configuration)?
.withTintColor(UIColor(hexStr: "#FF9AC1"), renderingMode: .alwaysOriginal)
.draw(in: CGRect(x: 0, y: 1, width: 15, height: 15))
UIImage(systemName: "heart.fill", withConfiguration: configuration)?
.withTintColor(UIColor(hexStr: "#FF78AC"), renderingMode: .alwaysOriginal)
.draw(in: CGRect(x: 8, y: 3, width: 14, height: 14))
return UIGraphicsGetImageFromCurrentImageContext()
}
private lazy var startMarkerLab: UILabel = makeMarkerLabel(text: "")
private lazy var endMarkerLab: UILabel = makeMarkerLabel(text: "")
private func makeMarkerLabel(text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = FontManager.youSheBiaoTiHei(11)
label.textColor = UIColor(hexStr: "#58EDFF")
label.textAlignment = .center
label.backgroundColor = UIColor(hexStr: "#293445")
label.cornerRadius = 7
label.clipsToBounds = true
return label
}
private lazy var startLocationLab: UILabel = makeLocationLabel()
private lazy var endLocationLab: UILabel = makeLocationLabel()
private func makeLocationLabel() -> UILabel {
let label = UILabel()
label.font = .systemFont(ofSize: 14, weight: .medium)
label.textColor = UIColor(hexStr: "#353B4F")
label.lineBreakMode = .byTruncatingTail
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return label
}
private lazy var durationLab: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 12, weight: .regular)
label.textColor = UIColor(hexStr: "#888888")
label.textAlignment = .center
label.backgroundColor = .white
label.lineBreakMode = .byTruncatingTail
return label
}()
private lazy var routeLineView: GroupItineraryDashLineView = {
GroupItineraryDashLineView()
}()
private lazy var tagScrollView: UIScrollView = {
let view = UIScrollView()
view.showsHorizontalScrollIndicator = false
view.alwaysBounceHorizontal = false
return view
}()
private lazy var tagStackView: UIStackView = {
let view = UIStackView()
view.axis = .horizontal
view.alignment = .fill
view.spacing = 6
view.addArrangedSubview(tagSpacerView)
return view
}()
private lazy var tagSpacerView: UIView = {
let view = UIView()
view.setContentHuggingPriority(.defaultLow, for: .horizontal)
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return view
}()
private func configureTags(_ names: [String]) {
tagStackView.arrangedSubviews.filter { $0 !== tagSpacerView }.forEach {
tagStackView.removeArrangedSubview($0)
$0.removeFromSuperview()
}
for name in names {
let label = PaddingLabel()
label.text = name
label.insets = UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7)
label.font = .systemFont(ofSize: 10, weight: .medium)
label.textColor = UIColor(hexStr: "#D5923F")
label.backgroundColor = UIColor(hexStr: "#FFF9DD")
label.cornerRadius = 4
label.clipsToBounds = true
label.setContentCompressionResistancePriority(.required, for: .horizontal)
tagStackView.addArrangedSubview(label)
}
tagScrollView.isHidden = names.isEmpty
}
private static func orderedPoints(_ points: [SchedulePointModel]) -> [SchedulePointModel] {
guard points.count > 1 else { return points }
let sequences = points.map(\.sequence)
guard Set(sequences).count == points.count else { return points }
return points.sorted { $0.sequence < $1.sequence }
}
private static func locationName(_ point: SchedulePointModel?, maximumCharacters: Int? = nil) -> String {
let name: String
if let point, !point.street.isEmpty {
name = point.street
} else if let point, !point.formatted_address.isEmpty {
name = point.formatted_address
} else {
name = "暂无地点"
}
guard let maximumCharacters, name.count > maximumCharacters else { return name }
return String(name.prefix(maximumCharacters)) + "..."
}
private static func durationText(from start: SchedulePointModel?, to end: SchedulePointModel?) -> String {
guard let start, let end,
start.id != end.id,
end.expected_timestamp > start.expected_timestamp else { return "--" }
let minutes = max(1, (end.expected_timestamp - start.expected_timestamp) / 60_000)
let hours = minutes / 60
let remainder = minutes % 60
return hours > 0 ? "\(hours)小时\(remainder)" : "\(minutes)分钟"
}
}
private final class GroupItineraryDashLineView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
layer.sublayers?.forEach { if $0 is CAShapeLayer { $0.removeFromSuperlayer() } }
let line = CAShapeLayer()
line.strokeColor = UIColor(hexStr: "#D6D6D6").cgColor
line.lineWidth = 1.5
line.lineDashPattern = [6, 6]
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: bounds.midY))
path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.midY))
line.path = path.cgPath
layer.addSublayer(line)
}
}
private final class PaddingLabel: UILabel {
var insets = UIEdgeInsets.zero
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: insets))
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(width: size.width + insets.left + insets.right,
height: size.height + insets.top + insets.bottom)
}
}