feat: update trends UI with chart
This commit is contained in:
parent
75bcbdb7a8
commit
5377adb39f
|
@ -347,6 +347,7 @@
|
|||
DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F9626367249008423CD /* SettingsViewController.swift */; };
|
||||
DB6F5E35264E78E7009108F4 /* AutoCompleteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */; };
|
||||
DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */; };
|
||||
DB71C7CB271D5A0300BE3819 /* LineChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71C7CA271D5A0300BE3819 /* LineChartView.swift */; };
|
||||
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; };
|
||||
DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; };
|
||||
DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; };
|
||||
|
@ -1141,6 +1142,7 @@
|
|||
DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
|
||||
DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewController.swift; sourceTree = "<group>"; };
|
||||
DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTopChevronView.swift; sourceTree = "<group>"; };
|
||||
DB71C7CA271D5A0300BE3819 /* LineChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartView.swift; sourceTree = "<group>"; };
|
||||
DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = "<group>"; };
|
||||
DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = "<group>"; };
|
||||
DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1954,6 +1956,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */,
|
||||
DB71C7CA271D5A0300BE3819 /* LineChartView.swift */,
|
||||
);
|
||||
path = View;
|
||||
sourceTree = "<group>";
|
||||
|
@ -4162,6 +4165,7 @@
|
|||
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */,
|
||||
DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */,
|
||||
2D34D9CB261489930081BFC0 /* SearchViewController+Recommend.swift in Sources */,
|
||||
DB71C7CB271D5A0300BE3819 /* LineChartView.swift in Sources */,
|
||||
DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */,
|
||||
DB482A45261335BA008AE74C /* UserTimelineViewController+Provider.swift in Sources */,
|
||||
2D206B8625F5FB0900143C56 /* Double.swift in Sources */,
|
||||
|
|
|
@ -17,7 +17,7 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
|
|||
|
||||
let hashtagTitleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = .white
|
||||
label.textColor = .label
|
||||
label.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
return label
|
||||
|
@ -25,18 +25,12 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
|
|||
|
||||
let peopleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = .white
|
||||
label.textColor = .label
|
||||
label.font = .preferredFont(forTextStyle: .body)
|
||||
return label
|
||||
}()
|
||||
|
||||
let flameIconView: UIImageView = {
|
||||
let imageView = UIImageView()
|
||||
let image = UIImage(systemName: "flame.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))!.withRenderingMode(.alwaysTemplate)
|
||||
imageView.image = image
|
||||
imageView.tintColor = .white
|
||||
return imageView
|
||||
}()
|
||||
let lineChartView = LineChartView()
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
@ -54,7 +48,7 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell {
|
|||
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
backgroundColor = isHighlighted ? Asset.Colors.brandBlueDarken20.color : Asset.Colors.brandBlue.color
|
||||
backgroundColor = isHighlighted ? .systemBackground.withAlphaComponent(0.8) : .systemBackground
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -68,7 +62,7 @@ extension SearchRecommendTagsCollectionViewCell {
|
|||
}
|
||||
|
||||
private func configure() {
|
||||
backgroundColor = Asset.Colors.brandBlue.color
|
||||
backgroundColor = .systemBackground
|
||||
layer.cornerRadius = 10
|
||||
layer.cornerCurve = .continuous
|
||||
clipsToBounds = false
|
||||
|
@ -98,41 +92,40 @@ extension SearchRecommendTagsCollectionViewCell {
|
|||
containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
|
||||
])
|
||||
|
||||
|
||||
let horizontalStackView = UIStackView()
|
||||
horizontalStackView.axis = .horizontal
|
||||
horizontalStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
horizontalStackView.distribution = .fill
|
||||
|
||||
hashtagTitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
hashtagTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .horizontal)
|
||||
horizontalStackView.addArrangedSubview(hashtagTitleLabel)
|
||||
horizontalStackView.setContentHuggingPriority(.required - 1, for: .vertical)
|
||||
|
||||
flameIconView.translatesAutoresizingMaskIntoConstraints = false
|
||||
horizontalStackView.addArrangedSubview(flameIconView)
|
||||
flameIconView.setContentHuggingPriority(.required - 1, for: .horizontal)
|
||||
|
||||
containerStackView.addArrangedSubview(horizontalStackView)
|
||||
peopleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
peopleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical)
|
||||
containerStackView.addArrangedSubview(hashtagTitleLabel)
|
||||
containerStackView.addArrangedSubview(peopleLabel)
|
||||
containerStackView.setCustomSpacing(SearchViewController.hashtagPeopleTalkingLabelTop, after: horizontalStackView)
|
||||
|
||||
lineChartView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(lineChartView)
|
||||
NSLayoutConstraint.activate([
|
||||
lineChartView.topAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 8),
|
||||
lineChartView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
|
||||
contentView.trailingAnchor.constraint(equalTo: lineChartView.trailingAnchor, constant: 16),
|
||||
contentView.bottomAnchor.constraint(equalTo: lineChartView.bottomAnchor, constant: 16),
|
||||
])
|
||||
}
|
||||
|
||||
func config(with tag: Mastodon.Entity.Tag) {
|
||||
hashtagTitleLabel.text = "# " + tag.name
|
||||
guard let historys = tag.history else {
|
||||
guard let history = tag.history else {
|
||||
peopleLabel.text = ""
|
||||
return
|
||||
}
|
||||
|
||||
let recentHistory = historys.prefix(2)
|
||||
let recentHistory = history.prefix(2)
|
||||
let peopleAreTalking = recentHistory.compactMap({ Int($0.accounts) }).reduce(0, +)
|
||||
let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking))
|
||||
peopleLabel.text = string
|
||||
|
||||
|
||||
lineChartView.data = history
|
||||
.sorted(by: { $0.day < $1.day }) // latest last
|
||||
.map { entry in
|
||||
guard let point = Int(entry.accounts) else {
|
||||
return .zero
|
||||
}
|
||||
return CGFloat(point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -23,9 +23,6 @@ final class SearchViewController: UIViewController, NeedsDependency {
|
|||
|
||||
public static var hashtagCardHeight: CGFloat {
|
||||
get {
|
||||
if UIScreen.main.bounds.size.height > 736 {
|
||||
return 186
|
||||
}
|
||||
return 130
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,151 @@
|
|||
//
|
||||
// LineChartView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by Cirno MainasuK on 2021-10-18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Accelerate
|
||||
import simd
|
||||
|
||||
final class LineChartView: UIView {
|
||||
|
||||
var data: [CGFloat] = [] {
|
||||
didSet {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
let lineShapeLayer = CAShapeLayer()
|
||||
let gradientLayer = CAGradientLayer()
|
||||
let dotShapeLayer = CAShapeLayer()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension LineChartView {
|
||||
private func _init() {
|
||||
lineShapeLayer.frame = bounds
|
||||
gradientLayer.frame = bounds
|
||||
dotShapeLayer.frame = bounds
|
||||
layer.addSublayer(lineShapeLayer)
|
||||
layer.addSublayer(gradientLayer)
|
||||
layer.addSublayer(dotShapeLayer)
|
||||
|
||||
gradientLayer.colors = [
|
||||
Asset.Colors.brandBlue.color.withAlphaComponent(0.5).cgColor,
|
||||
Asset.Colors.brandBlue.color.withAlphaComponent(0).cgColor,
|
||||
]
|
||||
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0)
|
||||
gradientLayer.endPoint = CGPoint(x: 0.5, y: 1)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
lineShapeLayer.frame = bounds
|
||||
gradientLayer.frame = bounds
|
||||
dotShapeLayer.frame = bounds
|
||||
|
||||
guard data.count > 1 else {
|
||||
lineShapeLayer.path = nil
|
||||
dotShapeLayer.path = nil
|
||||
gradientLayer.isHidden = true
|
||||
return
|
||||
}
|
||||
gradientLayer.isHidden = false
|
||||
|
||||
// Draw smooth chart
|
||||
// use vDSP scale the data with line interpolation method
|
||||
var data = data.map { Float($0) }
|
||||
// duplicate first and last value to prevent interpolation at edge data
|
||||
data.insert(data[0], at: 0)
|
||||
if let last = data.last {
|
||||
data.append(last)
|
||||
}
|
||||
|
||||
let n = vDSP_Length(128)
|
||||
let stride = vDSP_Stride(1)
|
||||
|
||||
// generate fine control with smoothing (simd_smoothstep(_:_:_:))
|
||||
let denominator = Float(n) / Float(data.count - 1)
|
||||
let control: [Float] = (0...n).map {
|
||||
let x = Float($0) / denominator
|
||||
return floor(x) + simd_smoothstep(0, 1, simd_fract(x))
|
||||
}
|
||||
|
||||
var points = [Float](repeating: 0, count: Int(n))
|
||||
vDSP_vlint(data,
|
||||
control, stride,
|
||||
&points, stride,
|
||||
n,
|
||||
vDSP_Length(data.count))
|
||||
|
||||
guard let maxDataPoint = data.max() else {
|
||||
return
|
||||
}
|
||||
func calculateY(for point: Float, in frame: CGRect) -> CGFloat {
|
||||
guard maxDataPoint > 0 else { return .zero }
|
||||
return (1 - CGFloat(point / maxDataPoint)) * frame.height
|
||||
}
|
||||
|
||||
let segmentCount = points.count - 1
|
||||
let segmentWidth = bounds.width / CGFloat(segmentCount)
|
||||
|
||||
let linePath = UIBezierPath()
|
||||
let dotPath = UIBezierPath()
|
||||
|
||||
// move to first data point
|
||||
var x: CGFloat = 0
|
||||
let y = calculateY(for: points[0], in: bounds)
|
||||
linePath.move(to: CGPoint(x: x, y: y))
|
||||
for point in points.dropFirst() {
|
||||
x += segmentWidth
|
||||
linePath.addLine(to: CGPoint(
|
||||
x: x,
|
||||
y: calculateY(for: point, in: bounds)
|
||||
))
|
||||
}
|
||||
|
||||
if let last = points.last {
|
||||
let y = calculateY(for: last, in: bounds)
|
||||
let center = CGPoint(x: bounds.maxX, y: y)
|
||||
dotPath.addArc(withCenter: center, radius: 3, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
|
||||
}
|
||||
|
||||
// this not works
|
||||
// linePath.lineJoinStyle = .round
|
||||
// lineShapeLayer.lineJoin = .round
|
||||
|
||||
lineShapeLayer.lineWidth = 3
|
||||
lineShapeLayer.strokeColor = Asset.Colors.brandBlue.color.cgColor
|
||||
lineShapeLayer.fillColor = UIColor.clear.cgColor
|
||||
lineShapeLayer.lineCap = .round
|
||||
lineShapeLayer.path = linePath.cgPath
|
||||
|
||||
let maskPath = UIBezierPath(cgPath: linePath.cgPath)
|
||||
maskPath.addLine(to: CGPoint(x: bounds.maxX, y: bounds.maxY))
|
||||
maskPath.addLine(to: CGPoint(x: bounds.minX, y: bounds.maxY))
|
||||
maskPath.close()
|
||||
let maskLayer = CAShapeLayer()
|
||||
maskLayer.path = maskPath.cgPath
|
||||
maskLayer.fillColor = UIColor.red.cgColor
|
||||
maskLayer.strokeColor = UIColor.clear.cgColor
|
||||
maskLayer.lineWidth = 0.0
|
||||
gradientLayer.mask = maskLayer
|
||||
|
||||
dotShapeLayer.lineWidth = 3
|
||||
dotShapeLayer.fillColor = Asset.Colors.brandBlue.color.cgColor
|
||||
dotShapeLayer.path = dotPath.cgPath
|
||||
}
|
||||
}
|
|
@ -22,6 +22,7 @@ extension Mastodon.Entity {
|
|||
public let url: String
|
||||
|
||||
public let history: [History]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case url
|
||||
|
|
Loading…
Reference in New Issue