diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 4337bdc7d..555329be9 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -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 = ""; }; DB6F5E34264E78E7009108F4 /* AutoCompleteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteViewController.swift; sourceTree = ""; }; DB6F5E37264E994A009108F4 /* AutoCompleteTopChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCompleteTopChevronView.swift; sourceTree = ""; }; + DB71C7CA271D5A0300BE3819 /* LineChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineChartView.swift; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; @@ -1954,6 +1956,7 @@ isa = PBXGroup; children = ( 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */, + DB71C7CA271D5A0300BE3819 /* LineChartView.swift */, ); path = View; sourceTree = ""; @@ -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 */, diff --git a/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index e555e1fac..a538d30bf 100644 --- a/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -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) + } } } diff --git a/Mastodon/Scene/Search/Search/SearchViewController.swift b/Mastodon/Scene/Search/Search/SearchViewController.swift index 8dcf9cd3b..c72945927 100644 --- a/Mastodon/Scene/Search/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/Search/SearchViewController.swift @@ -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 } } diff --git a/Mastodon/Scene/Search/Search/View/LineChartView.swift b/Mastodon/Scene/Search/Search/View/LineChartView.swift new file mode 100644 index 000000000..5b35bbc4f --- /dev/null +++ b/Mastodon/Scene/Search/Search/View/LineChartView.swift @@ -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 + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index e7f095eb3..740001572 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift @@ -22,6 +22,7 @@ extension Mastodon.Entity { public let url: String public let history: [History]? + enum CodingKeys: String, CodingKey { case name case url