This commit is contained in:
Ali 2020-02-11 15:42:38 +01:00
commit 63b85e07a4
173 changed files with 16061 additions and 4252 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -5303,3 +5303,22 @@ Any member of this group will be able to see messages in the channel.";
"Appearance.BubbleCorners.Apply" = "Set";
"Conversation.LiveLocationYouAndOther" = "**You** and %@";
"PeopleNearby.MakeVisible" = "Make Myself Visible";
"PeopleNearby.MakeInvisible" = "Stop Showing Me";
"PeopleNearby.ShowMorePeople_0" = "Show %@ More People";
"PeopleNearby.ShowMorePeople_1" = "Show %@ More People";
"PeopleNearby.ShowMorePeople_2" = "Show %@ More People";
"PeopleNearby.ShowMorePeople_3_10" = "Show %@ More People";
"PeopleNearby.ShowMorePeople_many" = "Show %@ More People";
"PeopleNearby.ShowMorePeople_any" = "Show %@ More People";
"PeopleNearby.VisibleUntil" = "visible until %@";
"PeopleNearby.MakeVisibleTitle" = "Make Myself Visible";
"PeopleNearby.MakeVisibleDescription" = "Allow people nearby to view your profile for 24 hours?\n\nYour phone number will remain hidden.";
"PeopleNearby.DiscoverDescription" = "Exchange contact info with people nearby\nand find new friends.";
"Time.TomorrowAt" = "tomorrow at %@";

View file

@ -42,9 +42,15 @@ public enum AnimatedStickerMode {
case direct
}
public enum AnimatedStickerPlaybackPosition {
case start
case end
}
public enum AnimatedStickerPlaybackMode {
case once
case loop
case still(AnimatedStickerPlaybackPosition)
}
private final class AnimatedStickerFrame {
@ -72,6 +78,7 @@ private protocol AnimatedStickerFrameSource: class {
var frameCount: Int { get }
func takeFrame() -> AnimatedStickerFrame?
func skipToEnd()
}
private final class AnimatedStickerFrameSourceWrapper {
@ -244,6 +251,9 @@ private final class AnimatedStickerCachedFrameSource: AnimatedStickerFrameSource
self.data = data
self.dataComplete = complete
}
func skipToEnd() {
}
}
private final class AnimatedStickerDirectFrameSource: AnimatedStickerFrameSource {
@ -289,6 +299,10 @@ private final class AnimatedStickerDirectFrameSource: AnimatedStickerFrameSource
}
return AnimatedStickerFrame(data: frameData, type: .argb, width: self.width, height: self.height, bytesPerRow: self.bytesPerRow, index: frameIndex, isLastFrame: frameIndex == self.frameCount - 1)
}
func skipToEnd() {
self.currentFrame = self.frameCount - 1
}
}
private final class AnimatedStickerFrameQueue {
@ -383,7 +397,7 @@ public final class AnimatedStickerNode: ASDisplayNode {
private var renderer: (AnimationRenderer & ASDisplayNode)?
private var isPlaying: Bool = false
public var isPlaying: Bool = false
private var canDisplayFirstFrame: Bool = false
private var playbackMode: AnimatedStickerPlaybackMode = .loop
@ -456,7 +470,9 @@ public final class AnimatedStickerNode: ASDisplayNode {
if let directData = try? Data(contentsOf: URL(fileURLWithPath: path), options: [.mappedRead]) {
strongSelf.directData = (directData, path, width, height)
}
if strongSelf.isPlaying {
if case let .still(position) = playbackMode {
strongSelf.seekTo(position)
} else if strongSelf.isPlaying {
strongSelf.play()
} else if strongSelf.canDisplayFirstFrame {
strongSelf.play(firstFrame: true)
@ -597,11 +613,11 @@ public final class AnimatedStickerNode: ASDisplayNode {
self.reportedStarted = false
self.timer.swap(nil)?.invalidate()
if self.playToCompletionOnStop {
self.seekToStart()
self.seekTo(.start)
}
}
public func seekToStart() {
public func seekTo(_ position: AnimatedStickerPlaybackPosition) {
self.isPlaying = false
let directData = self.directData
@ -612,6 +628,9 @@ public final class AnimatedStickerNode: ASDisplayNode {
var maybeFrameSource: AnimatedStickerFrameSource?
if let directData = directData {
maybeFrameSource = AnimatedStickerDirectFrameSource(queue: queue, data: directData.0, width: directData.2, height: directData.3)
if position == .end {
maybeFrameSource?.skipToEnd()
}
} else if let (cachedData, cachedDataComplete) = cachedData {
if #available(iOS 9.0, *) {
maybeFrameSource = AnimatedStickerCachedFrameSource(queue: queue, data: cachedData, complete: cachedDataComplete, notifyUpdated: {})

View file

@ -59,7 +59,12 @@ public final class CallListController: ViewController {
if case .tab = self.mode {
self.navigationItem.rightBarButtonItem = UIBarButtonItem(image: PresentationResourcesRootController.navigationCallIcon(self.presentationData.theme), style: .plain, target: self, action: #selector(self.callPressed))
let icon = UIImage(bundleImageName: "Chat List/Tabs/IconCalls")
let icon: UIImage?
if useSpecialTabBarIcons() {
icon = UIImage(bundleImageName: "Chat List/Tabs/Holiday/IconCalls")
} else {
icon = UIImage(bundleImageName: "Chat List/Tabs/IconCalls")
}
self.tabBarItem.title = self.presentationData.strings.Calls_TabTitle
self.tabBarItem.image = icon
self.tabBarItem.selectedImage = icon

31
submodules/Charts/BUCK Normal file
View file

@ -0,0 +1,31 @@
load("//Config:buck_rule_macros.bzl", "static_library")
static_library(
name = "Charts",
srcs = glob([
"Sources/**/*.swift",
]),
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit#shared",
"//submodules/AsyncDisplayKit:AsyncDisplayKit#shared",
"//submodules/Display:Display#shared",
"//submodules/Postbox:Postbox#shared",
"//submodules/TelegramCore:TelegramCore#shared",
"//submodules/SyncCore:SyncCore#shared",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
"//submodules/AccountContext:AccountContext",
"//submodules/ItemListUI:ItemListUI",
"//submodules/AvatarNode:AvatarNode",
"//submodules/TelegramStringFormatting:TelegramStringFormatting",
"//submodules/AlertUI:AlertUI",
"//submodules/PresentationDataUtils:PresentationDataUtils",
"//submodules/TelegramNotices:TelegramNotices",
"//submodules/MergeLists:MergeLists",
"//submodules/AppBundle:AppBundle",
],
frameworks = [
"$SDKROOT/System/Library/Frameworks/Foundation.framework",
"$SDKROOT/System/Library/Frameworks/UIKit.framework",
],
)

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
</dict>
</plist>

View file

@ -0,0 +1,258 @@
//
// ChartDetailsView.swift
// GraphTest
//
// Created by Andrew Solovey on 14/03/2019.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
private let cornerRadius: CGFloat = 5
private let verticalMargins: CGFloat = 8
private var labelHeight: CGFloat = 18
private var margin: CGFloat = 10
private var prefixLabelWidth: CGFloat = 27
private var textLabelWidth: CGFloat = 60
private var valueLabelWidth: CGFloat = 65
struct ChartDetailsViewModel {
struct Value {
let prefix: String?
let title: String
let value: String
let color: UIColor
let visible: Bool
}
var title: String
var showArrow: Bool
var showPrefixes: Bool
var values: [Value]
var totalValue: Value?
var tapAction: (() -> Void)?
static let blank = ChartDetailsViewModel(title: "", showArrow: false, showPrefixes: false, values: [], totalValue: nil, tapAction: nil)
}
class ChartDetailsView: UIControl {
let titleLabel = UILabel()
let arrowView = UIImageView()
var prefixViews: [UILabel] = []
var labelsViews: [UILabel] = []
var valuesViews: [UILabel] = []
private var viewModel: ChartDetailsViewModel?
private var colorMode: ColorMode = .day
static func fromNib() -> ChartDetailsView {
return Bundle.main.loadNibNamed("ChartDetailsView", owner: nil, options: nil)?.first as! ChartDetailsView
}
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = cornerRadius
clipsToBounds = true
addTarget(self, action: #selector(didTap), for: .touchUpInside)
titleLabel.font = UIFont.systemFont(ofSize: 12, weight: .bold)
arrowView.image = UIImage.arrowRight
arrowView.contentMode = .scaleAspectFill
addSubview(titleLabel)
addSubview(arrowView)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setup(viewModel: ChartDetailsViewModel, animated: Bool) {
self.viewModel = viewModel
titleLabel.setText(viewModel.title, animated: animated)
titleLabel.setVisible(!viewModel.title.isEmpty, animated: animated)
arrowView.setVisible(viewModel.showArrow, animated: animated)
let width: CGFloat = margin * 2 + (viewModel.showPrefixes ? (prefixLabelWidth + margin) : 0) + textLabelWidth + valueLabelWidth
var y: CGFloat = verticalMargins
if (!viewModel.title.isEmpty || viewModel.showArrow) {
titleLabel.frame = CGRect(x: margin, y: y, width: width, height: labelHeight)
arrowView.frame = CGRect(x: width - 6 - margin, y: margin, width: 6, height: 10)
y += labelHeight
}
let labelsCount: Int = viewModel.values.count + ((viewModel.totalValue == nil) ? 0 : 1)
setLabelsCount(array: &prefixViews,
count: viewModel.showPrefixes ? labelsCount : 0,
font: UIFont.systemFont(ofSize: 12, weight: .bold))
setLabelsCount(array: &labelsViews,
count: labelsCount,
font: UIFont.systemFont(ofSize: 12, weight: .regular),
textAlignment: .left)
setLabelsCount(array: &valuesViews,
count: labelsCount,
font: UIFont.systemFont(ofSize: 12, weight: .bold))
UIView.perform(animated: animated, animations: {
for (index, value) in viewModel.values.enumerated() {
var x: CGFloat = margin
if viewModel.showPrefixes {
let prefixLabel = self.prefixViews[index]
prefixLabel.textColor = self.colorMode.chartDetailsTextColor
prefixLabel.setText(value.prefix, animated: false)
prefixLabel.frame = CGRect(x: x, y: y, width: prefixLabelWidth, height: labelHeight)
x += prefixLabelWidth + margin
prefixLabel.alpha = value.visible ? 1 : 0
}
let titleLabel = self.labelsViews[index]
titleLabel.setTextColor(self.colorMode.chartDetailsTextColor, animated: false)
titleLabel.setText(value.title, animated: false)
titleLabel.frame = CGRect(x: x, y: y, width: textLabelWidth, height: labelHeight)
titleLabel.alpha = value.visible ? 1 : 0
x += textLabelWidth
let valueLabel = self.valuesViews[index]
valueLabel.setTextColor(value.color, animated: false)
valueLabel.setText(value.value, animated: false)
valueLabel.frame = CGRect(x: x, y: y, width: valueLabelWidth, height: labelHeight)
valueLabel.alpha = value.visible ? 1 : 0
if value.visible {
y += labelHeight
}
}
if let value = viewModel.totalValue {
var x: CGFloat = margin
if viewModel.showPrefixes {
let prefixLabel = self.prefixViews[viewModel.values.count]
prefixLabel.textColor = self.colorMode.chartDetailsTextColor
prefixLabel.setText(value.prefix, animated: false)
prefixLabel.frame = CGRect(x: x, y: y, width: prefixLabelWidth, height: labelHeight)
prefixLabel.alpha = value.visible ? 1 : 0
x += prefixLabelWidth + margin
}
let titleLabel = self.labelsViews[viewModel.values.count]
titleLabel.setTextColor(self.colorMode.chartDetailsTextColor, animated: false)
titleLabel.setText(value.title, animated: false)
titleLabel.frame = CGRect(x: x, y: y, width: textLabelWidth, height: labelHeight)
titleLabel.alpha = value.visible ? 1 : 0
x += textLabelWidth
let valueLabel = self.valuesViews[viewModel.values.count]
valueLabel.setTextColor(self.colorMode.chartDetailsTextColor, animated: false)
valueLabel.setText(value.value, animated: false)
valueLabel.frame = CGRect(x: x, y: y, width: valueLabelWidth, height: labelHeight)
valueLabel.alpha = value.visible ? 1 : 0
}
})
}
override var intrinsicContentSize: CGSize {
if let viewModel = viewModel {
let height = ((!viewModel.title.isEmpty || viewModel.showArrow) ? labelHeight : 0) +
(CGFloat(viewModel.values.filter({ $0.visible }).count) * labelHeight) +
(viewModel.totalValue?.visible == true ? labelHeight : 0) +
verticalMargins * 2
let width: CGFloat = margin * 2 +
(viewModel.showPrefixes ? (prefixLabelWidth + margin) : 0) +
textLabelWidth +
valueLabelWidth
return CGSize(width: width,
height: height)
} else {
return CGSize(width: 140,
height: labelHeight + verticalMargins)
}
}
@objc private func didTap() {
viewModel?.tapAction?()
}
func setLabelsCount(array: inout [UILabel],
count: Int,
font: UIFont,
textAlignment: NSTextAlignment = .right) {
while array.count > count {
let subview = array.removeLast()
subview.removeFromSuperview()
}
while array.count < count {
let label = UILabel()
label.font = font
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.5
label.textAlignment = textAlignment
addSubview(label)
array.append(label)
}
}
}
extension ChartDetailsView: ColorModeContainer {
func apply(colorMode: ColorMode, animated: Bool) {
self.colorMode = colorMode
self.titleLabel.setTextColor(colorMode.chartDetailsTextColor, animated: animated)
if let viewModel = self.viewModel {
self.setup(viewModel: viewModel, animated: animated)
}
UIView.perform(animated: animated) {
self.arrowView.tintColor = colorMode.chartDetailsArrowColor
self.backgroundColor = colorMode.chartDetailsViewColor
}
}
}
// MARK: UIStackView+removeAllArrangedSubviews
public extension UIStackView {
func setLabelsCount(_ count: Int,
font: UIFont,
huggingPriority: UILayoutPriority,
textAlignment: NSTextAlignment = .right) {
while arrangedSubviews.count > count {
let subview = arrangedSubviews.last!
removeArrangedSubview(subview)
subview.removeFromSuperview()
}
while arrangedSubviews.count < count {
let label = UILabel()
label.font = font
label.textAlignment = textAlignment
label.setContentHuggingPriority(huggingPriority, for: .horizontal)
label.setContentHuggingPriority(huggingPriority, for: .vertical)
label.setContentCompressionResistancePriority(UILayoutPriority(rawValue: 999), for: .horizontal)
label.setContentCompressionResistancePriority(UILayoutPriority(rawValue: 999), for: .vertical)
addArrangedSubview(label)
}
}
func label(at index: Int) -> UILabel {
return arrangedSubviews[index] as! UILabel
}
func removeAllArrangedSubviews() {
for subview in arrangedSubviews {
removeArrangedSubview(subview)
subview.removeFromSuperview()
}
}
}
// MARK: UIStackView+addArrangedSubviews
public extension UIStackView {
func addArrangedSubviews(_ views: [UIView]) {
views.forEach({ addArrangedSubview($0) })
}
}
// MARK: UIStackView+insertArrangedSubviews
public extension UIStackView {
func insertArrangedSubviews(_ views: [UIView], at index: Int) {
views.reversed().forEach({ insertArrangedSubview($0, at: index) })
}
}

View file

@ -0,0 +1,199 @@
//
// ChartStackSection.swift
// GraphTest
//
// Created by Andrei Salavei on 4/13/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
private enum Constants {
static let chartViewHeightFraction: CGFloat = 0.55
}
class ChartStackSection: UIView, ColorModeContainer {
var chartView: ChartView
var rangeView: RangeChartView
var visibilityView: ChartVisibilityView
var sectionContainerView: UIView
var separators: [UIView] = []
var headerLabel: UILabel!
var titleLabel: UILabel!
var backButton: UIButton!
var controller: BaseChartController!
init() {
sectionContainerView = UIView()
chartView = ChartView()
rangeView = RangeChartView()
visibilityView = ChartVisibilityView()
headerLabel = UILabel()
titleLabel = UILabel()
backButton = UIButton()
super.init(frame: CGRect())
self.addSubview(sectionContainerView)
sectionContainerView.addSubview(chartView)
sectionContainerView.addSubview(rangeView)
sectionContainerView.addSubview(visibilityView)
headerLabel.font = UIFont.systemFont(ofSize: 14, weight: .regular)
titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .bold)
visibilityView.clipsToBounds = true
backButton.isExclusiveTouch = true
backButton.setVisible(false, animated: false)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func awakeFromNib() {
super.awakeFromNib()
headerLabel.font = UIFont.systemFont(ofSize: 14, weight: .regular)
titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .bold)
visibilityView.clipsToBounds = true
backButton.isExclusiveTouch = true
backButton.setVisible(false, animated: false)
}
func apply(colorMode: ColorMode, animated: Bool) {
UIView.perform(animated: animated && self.isVisibleInWindow) {
self.backgroundColor = colorMode.tableBackgroundColor
self.sectionContainerView.backgroundColor = colorMode.chartBackgroundColor
self.rangeView.backgroundColor = colorMode.chartBackgroundColor
self.visibilityView.backgroundColor = colorMode.chartBackgroundColor
self.backButton.tintColor = colorMode.actionButtonColor
self.backButton.setTitleColor(colorMode.actionButtonColor, for: .normal)
for separator in self.separators {
separator.backgroundColor = colorMode.tableSeparatorColor
}
}
if rangeView.isVisibleInWindow || chartView.isVisibleInWindow {
chartView.loadDetailsViewIfNeeded()
chartView.apply(colorMode: colorMode, animated: animated && chartView.isVisibleInWindow)
controller.apply(colorMode: colorMode, animated: animated)
rangeView.apply(colorMode: colorMode, animated: animated && rangeView.isVisibleInWindow)
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + TimeInterval.random(in: 0...0.1)) {
self.chartView.loadDetailsViewIfNeeded()
self.controller.apply(colorMode: colorMode, animated: false)
self.chartView.apply(colorMode: colorMode, animated: false)
self.rangeView.apply(colorMode: colorMode, animated: false)
}
}
self.titleLabel.setTextColor(colorMode.chartTitleColor, animated: animated && titleLabel.isVisibleInWindow)
self.headerLabel.setTextColor(colorMode.sectionTitleColor, animated: animated && headerLabel.isVisibleInWindow)
}
@IBAction func didTapBackButton() {
controller.didTapZoomOut()
}
func setBackButtonVisible(_ visible: Bool, animated: Bool) {
backButton.setVisible(visible, animated: animated)
layoutIfNeeded(animated: animated)
}
func updateToolViews(animated: Bool) {
rangeView.setRange(controller.currentChartHorizontalRangeFraction, animated: animated)
rangeView.setRangePaging(enabled: controller.isChartRangePagingEnabled,
minimumSize: controller.minimumSelectedChartRange)
visibilityView.setVisible(controller.drawChartVisibity, animated: animated)
if controller.drawChartVisibity {
visibilityView.isExpanded = true
visibilityView.items = controller.actualChartsCollection.chartValues.map { value in
return ChartVisibilityItem(title: value.name, color: value.color)
}
visibilityView.setItemsSelection(controller.actualChartVisibility)
visibilityView.setNeedsLayout()
visibilityView.layoutIfNeeded()
} else {
visibilityView.isExpanded = false
}
superview?.superview?.layoutIfNeeded(animated: animated)
}
override func layoutSubviews() {
super.layoutSubviews()
let bounds = self.bounds
self.sectionContainerView.frame = CGRect(origin: CGPoint(), size: CGSize(width: bounds.width, height: 350.0))
self.chartView.frame = CGRect(origin: CGPoint(), size: CGSize(width: bounds.width, height: 250.0))
self.rangeView.frame = CGRect(origin: CGPoint(x: 0.0, y: 250.0), size: CGSize(width: bounds.width, height: 48.0))
self.visibilityView.frame = CGRect(origin: CGPoint(x: 0.0, y: 308.0), size: CGSize(width: bounds.width, height: 122.0))
}
func setup(controller: BaseChartController, title: String) {
self.controller = controller
self.headerLabel.text = title
// Chart
chartView.renderers = controller.mainChartRenderers
chartView.userDidSelectCoordinateClosure = { [unowned self] point in
self.controller.chartInteractionDidBegin(point: point)
}
chartView.userDidDeselectCoordinateClosure = { [unowned self] in
self.controller.chartInteractionDidEnd()
}
controller.cartViewBounds = { [unowned self] in
return self.chartView.bounds
}
controller.chartFrame = { [unowned self] in
return self.chartView.chartFrame
}
controller.setDetailsViewModel = { [unowned self] viewModel, animated in
self.chartView.setDetailsViewModel(viewModel: viewModel, animated: animated)
}
controller.setDetailsChartVisibleClosure = { [unowned self] visible, animated in
self.chartView.setDetailsChartVisible(visible, animated: animated)
}
controller.setDetailsViewPositionClosure = { [unowned self] position in
self.chartView.detailsViewPosition = position
}
controller.setChartTitleClosure = { [unowned self] title, animated in
self.titleLabel.setText(title, animated: animated)
}
controller.setBackButtonVisibilityClosure = { [unowned self] visible, animated in
self.setBackButtonVisible(visible, animated: animated)
}
controller.refreshChartToolsClosure = { [unowned self] animated in
self.updateToolViews(animated: animated)
}
// Range view
rangeView.chartView.renderers = controller.navigationRenderers
rangeView.rangeDidChangeClosure = { range in
controller.updateChartRange(range)
}
rangeView.touchedOutsideClosure = {
controller.cancelChartInteraction()
}
controller.chartRangeUpdatedClosure = { [unowned self] (range, animated) in
self.rangeView.setRange(range, animated: animated)
}
controller.chartRangePagingClosure = { [unowned self] (isEnabled, pageSize) in
self.rangeView.setRangePaging(enabled: isEnabled, minimumSize: pageSize)
}
// Visibility view
visibilityView.selectionCallbackClosure = { [unowned self] visibility in
self.controller.updateChartsVisibility(visibility: visibility, animated: true)
}
controller.initializeChart()
updateToolViews(animated: false)
}
}

View file

@ -0,0 +1,149 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="ChartStackSection" customModule="GraphTest" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="421" height="485"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="6Pd-im-6vD">
<rect key="frame" x="0.0" y="54" width="421" height="430"/>
<subviews>
<view clipsSubviews="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Gs1-ng-qdf" customClass="ChartView" customModule="GraphTest" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="421" height="250"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="910" verticalHuggingPriority="910" horizontalCompressionResistancePriority="960" verticalCompressionResistancePriority="960" contentHorizontalAlignment="left" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Gze-ly-E3P">
<rect key="frame" x="16" y="0.0" width="100" height="40"/>
<constraints>
<constraint firstAttribute="width" constant="100" id="0fS-5e-eL0"/>
<constraint firstAttribute="height" priority="937" constant="38" id="Qll-Xf-A2W"/>
<constraint firstAttribute="width" secondItem="Gze-ly-E3P" secondAttribute="height" multiplier="20:8" id="eWA-Ka-ghV"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<inset key="contentEdgeInsets" minX="0.0" minY="0.0" maxX="2" maxY="0.0"/>
<state key="normal" title="Zoom Out" image="arrow_left">
<color key="titleColor" red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</state>
<state key="selected">
<color key="titleColor" red="0.0" green="0.47843137250000001" blue="1" alpha="0.38792913732394368" colorSpace="custom" customColorSpace="sRGB"/>
</state>
<connections>
<action selector="didTapBackButton" destination="iN0-l3-epB" eventType="touchUpInside" id="zsN-Ne-pKH"/>
</connections>
</button>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="800" verticalHuggingPriority="800" horizontalCompressionResistancePriority="850" verticalCompressionResistancePriority="850" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="kGq-zn-SjL">
<rect key="frame" x="189.5" y="0.0" width="42" height="38"/>
<constraints>
<constraint firstAttribute="height" constant="38" id="Ngt-jk-4ri"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="Gze-ly-E3P" firstAttribute="leading" secondItem="Gs1-ng-qdf" secondAttribute="leading" constant="16" id="4qc-36-ziS"/>
<constraint firstItem="kGq-zn-SjL" firstAttribute="top" secondItem="Gs1-ng-qdf" secondAttribute="top" id="HG2-7K-e3g"/>
<constraint firstAttribute="height" constant="250" id="PH2-EY-kJT"/>
<constraint firstItem="kGq-zn-SjL" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="Gze-ly-E3P" secondAttribute="trailing" constant="4" id="V4X-jJ-C0F"/>
<constraint firstItem="kGq-zn-SjL" firstAttribute="centerX" secondItem="Gs1-ng-qdf" secondAttribute="centerX" priority="500" id="g1F-0x-Gyq"/>
<constraint firstItem="Gze-ly-E3P" firstAttribute="centerY" secondItem="Gs1-ng-qdf" secondAttribute="top" constant="20" id="rbC-Ih-Hxc"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="kGq-zn-SjL" secondAttribute="trailing" priority="999" constant="16" id="wyb-Ls-dPG"/>
</constraints>
</view>
<view clipsSubviews="YES" contentMode="scaleToFill" horizontalHuggingPriority="900" verticalHuggingPriority="900" horizontalCompressionResistancePriority="950" verticalCompressionResistancePriority="950" translatesAutoresizingMaskIntoConstraints="NO" id="7Oh-hd-0e7" customClass="RangeChartView" customModule="GraphTest" customModuleProvider="target">
<rect key="frame" x="0.0" y="250" width="421" height="58"/>
<color key="backgroundColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="58" id="XGF-mc-YZQ"/>
</constraints>
<edgeInsets key="layoutMargins" top="1" left="16" bottom="17" right="16"/>
</view>
<view clipsSubviews="YES" contentMode="scaleToFill" horizontalHuggingPriority="900" verticalHuggingPriority="900" horizontalCompressionResistancePriority="950" verticalCompressionResistancePriority="950" translatesAutoresizingMaskIntoConstraints="NO" id="uuw-Jy-472" customClass="ChartVisibilityView" customModule="GraphTest" customModuleProvider="target">
<rect key="frame" x="0.0" y="308" width="421" height="122"/>
<color key="backgroundColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<edgeInsets key="layoutMargins" top="1" left="16" bottom="1" right="16"/>
</view>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="bottom" secondItem="uuw-Jy-472" secondAttribute="bottom" id="0DO-9R-O0l"/>
<constraint firstItem="Gs1-ng-qdf" firstAttribute="leading" secondItem="6Pd-im-6vD" secondAttribute="leading" id="8wc-1j-dh7"/>
<constraint firstAttribute="trailing" secondItem="Gs1-ng-qdf" secondAttribute="trailing" id="Ax0-cp-SF2"/>
<constraint firstItem="7Oh-hd-0e7" firstAttribute="top" secondItem="Gs1-ng-qdf" secondAttribute="bottom" id="I8A-x2-oZG"/>
<constraint firstAttribute="trailing" secondItem="uuw-Jy-472" secondAttribute="trailing" id="Z1f-t1-4GJ"/>
<constraint firstItem="7Oh-hd-0e7" firstAttribute="leading" secondItem="6Pd-im-6vD" secondAttribute="leading" id="dCz-H4-an3"/>
<constraint firstAttribute="trailing" secondItem="7Oh-hd-0e7" secondAttribute="trailing" id="hQM-uW-x9q"/>
<constraint firstItem="uuw-Jy-472" firstAttribute="leading" secondItem="6Pd-im-6vD" secondAttribute="leading" id="htA-IO-x7c"/>
<constraint firstItem="uuw-Jy-472" firstAttribute="top" secondItem="7Oh-hd-0e7" secondAttribute="bottom" id="kx7-e3-2UX"/>
<constraint firstItem="Gs1-ng-qdf" firstAttribute="top" secondItem="6Pd-im-6vD" secondAttribute="top" id="zN5-uX-gl4"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="900" verticalHuggingPriority="900" horizontalCompressionResistancePriority="910" verticalCompressionResistancePriority="910" text="_Header" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="TAY-o7-MHD">
<rect key="frame" x="16" y="27" width="405" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="4QI-3Q-rtc">
<rect key="frame" x="0.0" y="53" width="421" height="1"/>
<color key="backgroundColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="1" id="TSt-sG-IxH" customClass="OnePixelConstrain" customModule="GraphTest" customModuleProvider="target"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="AEQ-wS-3p4">
<rect key="frame" x="0.0" y="484" width="421" height="1"/>
<color key="backgroundColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="1" id="q3W-Ii-wV7" customClass="OnePixelConstrain" customModule="GraphTest" customModuleProvider="target"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" cocoaTouchSystemColor="groupTableViewBackgroundColor"/>
<constraints>
<constraint firstItem="4QI-3Q-rtc" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="1oT-KU-FRt"/>
<constraint firstAttribute="trailing" secondItem="4QI-3Q-rtc" secondAttribute="trailing" id="3Z5-bY-93t"/>
<constraint firstAttribute="bottom" secondItem="AEQ-wS-3p4" secondAttribute="bottom" priority="999" id="4cm-gK-MnK"/>
<constraint firstItem="AEQ-wS-3p4" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="Bah-zR-bfE"/>
<constraint firstItem="TAY-o7-MHD" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" constant="27" id="GIj-mI-5dl"/>
<constraint firstItem="AEQ-wS-3p4" firstAttribute="top" secondItem="6Pd-im-6vD" secondAttribute="bottom" id="GRz-lz-VD5"/>
<constraint firstItem="4QI-3Q-rtc" firstAttribute="top" secondItem="TAY-o7-MHD" secondAttribute="bottom" constant="5" id="T77-y7-mHo"/>
<constraint firstItem="6Pd-im-6vD" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="XLV-JP-MAz"/>
<constraint firstItem="TAY-o7-MHD" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="caV-uR-OCt"/>
<constraint firstItem="6Pd-im-6vD" firstAttribute="top" secondItem="4QI-3Q-rtc" secondAttribute="bottom" id="fcf-nY-X5q"/>
<constraint firstAttribute="trailing" secondItem="TAY-o7-MHD" secondAttribute="trailing" id="fyT-jz-qX4"/>
<constraint firstAttribute="trailing" secondItem="AEQ-wS-3p4" secondAttribute="trailing" id="xSX-Xx-73t"/>
<constraint firstAttribute="trailing" secondItem="6Pd-im-6vD" secondAttribute="trailing" id="yOk-xT-or5"/>
</constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="backButton" destination="Gze-ly-E3P" id="tjz-dk-9Re"/>
<outlet property="backButtonWidthConstraint" destination="0fS-5e-eL0" id="CkZ-8G-YjF"/>
<outlet property="chartHeightConstraint" destination="PH2-EY-kJT" id="W0R-Ms-q48"/>
<outlet property="chartView" destination="Gs1-ng-qdf" id="fSW-zn-WuU"/>
<outlet property="headerLabel" destination="TAY-o7-MHD" id="aNR-Oo-dMR"/>
<outlet property="rangeView" destination="7Oh-hd-0e7" id="LaC-Mf-kKQ"/>
<outlet property="sectionContainerView" destination="6Pd-im-6vD" id="lNz-Oe-uVZ"/>
<outlet property="titleLabel" destination="kGq-zn-SjL" id="lhp-Hk-PQi"/>
<outlet property="visibilityView" destination="uuw-Jy-472" id="rZn-nk-wqx"/>
<outletCollection property="separators" destination="4QI-3Q-rtc" collectionClass="NSMutableArray" id="keK-ua-xv6"/>
<outletCollection property="separators" destination="AEQ-wS-3p4" collectionClass="NSMutableArray" id="7We-sB-fTh"/>
</connections>
<point key="canvasLocation" x="-217" y="61"/>
</view>
</objects>
<resources>
<image name="arrow_left" width="15" height="15"/>
</resources>
</document>

View file

@ -0,0 +1,158 @@
//
// ChartView.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
public protocol ChartViewRenderer: class {
var containerViews: [UIView] { get set }
func render(context: CGContext, bounds: CGRect, chartFrame: CGRect)
}
class ChartView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupView()
}
var chartInsets: UIEdgeInsets = UIEdgeInsets(top: 40, left: 16, bottom: 35, right: 16) {
didSet {
setNeedsDisplay()
}
}
var renderers: [ChartViewRenderer] = [] {
willSet {
renderers.forEach { $0.containerViews.removeAll(where: { $0 == self }) }
}
didSet {
renderers.forEach { $0.containerViews.append(self) }
setNeedsDisplay()
}
}
var chartFrame: CGRect {
let chartBound = self.bounds
return CGRect(x: chartInsets.left,
y: chartInsets.top,
width: max(1, chartBound.width - chartInsets.left - chartInsets.right),
height: max(1, chartBound.height - chartInsets.top - chartInsets.bottom))
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
let chartBounds = self.bounds
let chartFrame = self.chartFrame
for renderer in renderers {
renderer.render(context: context, bounds: chartBounds, chartFrame: chartFrame)
}
}
var userDidSelectCoordinateClosure: ((CGPoint) -> Void)?
var userDidDeselectCoordinateClosure: (() -> Void)?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
if let point = touches.first?.location(in: self) {
let fractionPoint = CGPoint(x: (point.x - chartFrame.origin.x) / chartFrame.width,
y: (point.y - chartFrame.origin.y) / chartFrame.height)
userDidSelectCoordinateClosure?(fractionPoint)
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
if let point = touches.first?.location(in: self) {
let fractionPoint = CGPoint(x: (point.x - chartFrame.origin.x) / chartFrame.width,
y: (point.y - chartFrame.origin.y) / chartFrame.height)
userDidSelectCoordinateClosure?(fractionPoint)
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
userDidDeselectCoordinateClosure?()
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
userDidDeselectCoordinateClosure?()
}
// MARK: Details View
private var detailsView: ChartDetailsView!
private var maxDetailsViewWidth: CGFloat = 0
func loadDetailsViewIfNeeded() {
if detailsView == nil {
let detailsView = ChartDetailsView(frame: bounds)
addSubview(detailsView)
detailsView.alpha = 0
self.detailsView = detailsView
}
}
private var detailsTableTopOffset: CGFloat = 5
private var detailsTableLeftOffset: CGFloat = 8
private var isDetailsViewVisible: Bool = false
var detailsViewPosition: CGFloat = 0 {
didSet {
loadDetailsViewIfNeeded()
let detailsViewSize = detailsView.intrinsicContentSize
maxDetailsViewWidth = max(maxDetailsViewWidth, detailsViewSize.width)
if maxDetailsViewWidth + detailsTableLeftOffset > detailsViewPosition {
detailsView.frame = CGRect(x: min(detailsViewPosition + detailsTableLeftOffset, bounds.width - maxDetailsViewWidth),
y: chartInsets.top + detailsTableTopOffset,
width: maxDetailsViewWidth,
height: detailsViewSize.height)
} else {
detailsView.frame = CGRect(x: detailsViewPosition - maxDetailsViewWidth - detailsTableLeftOffset,
y: chartInsets.top + detailsTableTopOffset,
width: maxDetailsViewWidth,
height: detailsViewSize.height)
}
}
}
func setDetailsChartVisible(_ visible: Bool, animated: Bool) {
guard isDetailsViewVisible != visible else {
return
}
isDetailsViewVisible = visible
loadDetailsViewIfNeeded()
detailsView.setVisible(visible, animated: animated)
if !visible {
maxDetailsViewWidth = 0
}
}
func setDetailsViewModel(viewModel: ChartDetailsViewModel, animated: Bool) {
loadDetailsViewIfNeeded()
detailsView.setup(viewModel: viewModel, animated: animated)
UIView.perform(animated: animated, animations: {
let position = self.detailsViewPosition
self.detailsViewPosition = position
})
}
func setupView() {
backgroundColor = .clear
layer.drawsAsynchronously = true
}
}
extension ChartView: ColorModeContainer {
func apply(colorMode: ColorMode, animated: Bool) {
detailsView?.apply(colorMode: colorMode, animated: animated && (detailsView?.isVisibleInWindow ?? false))
}
}

View file

@ -0,0 +1,95 @@
//
// ChartVisibilityItemCell.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class ChartVisibilityItemView: UIView {
static let textFont = UIFont.systemFont(ofSize: 14, weight: .medium)
let checkButton: UIButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override func awakeFromNib() {
super.awakeFromNib()
setupView()
}
func setupView() {
checkButton.frame = bounds
checkButton.titleLabel?.font = ChartVisibilityItemView.textFont
checkButton.layer.cornerRadius = 6
checkButton.layer.masksToBounds = true
checkButton.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
let pressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(didRecognizedLongPress(recognizer:)))
pressRecognizer.cancelsTouchesInView = true
checkButton.addGestureRecognizer(pressRecognizer)
addSubview(checkButton)
}
var tapClosure: (() -> Void)?
var longTapClosure: (() -> Void)?
private func updateStyle(animated: Bool) {
guard let item = item else {
return
}
UIView.perform(animated: animated, animations: {
if self.isChecked {
self.checkButton.setTitleColor(.white, for: .normal)
self.checkButton.backgroundColor = item.color
self.checkButton.layer.borderColor = nil
self.checkButton.layer.borderWidth = 0
self.checkButton.setTitle("" + item.title, for: .normal)
} else {
self.checkButton.backgroundColor = .clear
self.checkButton.layer.borderColor = item.color.cgColor
self.checkButton.layer.borderWidth = 1
self.checkButton.setTitleColor(item.color, for: .normal)
self.checkButton.setTitle(item.title, for: .normal)
}
})
}
override func layoutSubviews() {
super.layoutSubviews()
checkButton.frame = bounds
}
@objc private func didTapButton() {
tapClosure?()
}
@objc private func didRecognizedLongPress(recognizer: UIGestureRecognizer) {
if recognizer.state == .began {
longTapClosure?()
}
}
var item: ChartVisibilityItem? = nil {
didSet {
updateStyle(animated: false)
}
}
private(set) var isChecked: Bool = true
func setChecked(isChecked: Bool, animated: Bool) {
self.isChecked = isChecked
updateStyle(animated: true)
}
}

View file

@ -0,0 +1,147 @@
//
// ChartVisibilityView.swift
// GraphTest
//
// Created by Andrei Salavei on 4/13/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
private enum Constants {
static let itemHeight: CGFloat = 30
static let itemSpacing: CGFloat = 8
static let labelTextApproxInsets: CGFloat = 40
static let insets = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16)
}
struct ChartVisibilityItem {
var title: String
var color: UIColor
}
class ChartVisibilityView: UIView {
var items: [ChartVisibilityItem] = [] {
didSet {
selectedItems = items.map { _ in true }
while selectionViews.count > selectedItems.count {
selectionViews.last?.removeFromSuperview()
selectionViews.removeLast()
}
while selectionViews.count < selectedItems.count {
let view = ChartVisibilityItemView(frame: bounds)
addSubview(view)
selectionViews.append(view)
}
for (index, item) in items.enumerated() {
let view = selectionViews[index]
view.item = item
view.tapClosure = { [weak self] in
guard let self = self else { return }
self.setItemSelected(!self.selectedItems[index], at: index, animated: true)
self.notifyItemSelection()
}
view.longTapClosure = { [weak self] in
guard let self = self else { return }
let hasSelectedItem = self.selectedItems.enumerated().contains(where: { $0.element && $0.offset != index })
if hasSelectedItem {
for (itemIndex, _) in self.items.enumerated() {
self.setItemSelected(itemIndex == index, at: itemIndex, animated: true)
}
} else {
for (itemIndex, _) in self.items.enumerated() {
self.setItemSelected(true, at: itemIndex, animated: true)
}
}
self.notifyItemSelection()
}
}
}
}
private (set) var selectedItems: [Bool] = []
var isExpanded: Bool = true {
didSet {
invalidateIntrinsicContentSize()
setNeedsUpdateConstraints()
}
}
private var selectionViews: [ChartVisibilityItemView] = []
private func generateItemsFrames(frame: CGRect) -> [CGRect] {
var previousPoint = CGPoint(x: Constants.insets.left, y: Constants.insets.top)
var frames: [CGRect] = []
for item in items {
let labelSize = (item.title as NSString).size(withAttributes: [.font: ChartVisibilityItemView.textFont])
let width = (labelSize.width + Constants.labelTextApproxInsets).rounded(.up)
if previousPoint.x + width < (frame.width - Constants.insets.left - Constants.insets.right) {
frames.append(CGRect(origin: previousPoint, size: CGSize(width: width, height: Constants.itemHeight)))
} else if previousPoint.x <= Constants.insets.left {
frames.append(CGRect(origin: previousPoint, size: CGSize(width: width, height: Constants.itemHeight)))
} else {
previousPoint.y += Constants.itemHeight + Constants.itemSpacing
previousPoint.x = Constants.insets.left
frames.append(CGRect(origin: previousPoint, size: CGSize(width: width, height: Constants.itemHeight)))
}
previousPoint.x += width + Constants.itemSpacing
}
return frames
}
var selectionCallbackClosure: (([Bool]) -> Void)?
func setItemSelected(_ selected: Bool, at index: Int, animated: Bool) {
self.selectedItems[index] = selected
self.selectionViews[index].setChecked(isChecked: selected, animated: animated)
}
func setItemsSelection(_ selection: [Bool]) {
assert(selection.count == items.count)
self.selectedItems = selection
for (index, selected) in self.selectedItems.enumerated() {
selectionViews[index].setChecked(isChecked: selected, animated: false)
}
}
private func notifyItemSelection() {
selectionCallbackClosure?(selectedItems)
}
override func layoutSubviews() {
super.layoutSubviews()
updateFrames()
}
private func updateFrames() {
for (index, frame) in generateItemsFrames(frame: bounds).enumerated() {
selectionViews[index].frame = frame
}
}
override var intrinsicContentSize: CGSize {
guard isExpanded else {
var size = self.bounds.size
size.height = 0
return size
}
let frames = generateItemsFrames(frame: UIScreen.main.bounds)
guard let lastFrame = frames.last else { return .zero }
let size = CGSize(width: frame.width, height: lastFrame.maxY + Constants.insets.bottom)
return size
}
}
extension ChartVisibilityView: ColorModeContainer {
func apply(colorMode: ColorMode, animated: Bool) {
UIView.perform(animated: animated) {
self.backgroundColor = colorMode.chartBackgroundColor
self.tintColor = colorMode.descriptionActionColor
}
}
}

View file

@ -0,0 +1,51 @@
//
// ChartsDataLoader.swift
// GraphTest
//
// Created by Andrei Salavei on 4/8/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
enum ChartsDataType: String {
case generalLines = "1"
case twoAxisLines = "2"
case stackedBars = "3"
case dailyBars = "4"
case percentPie = "5"
}
private enum Constants {
static let overviewFilename = "overview.json"
static let dataDir = "data"
}
class ChartsDataLoader {
static var documentDirectoryURL: URL {
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
let documentsDirectory = paths[0]
return documentsDirectory
}
static func overviewData(type: ChartsDataType, extraCopiesCount: Int = 0, sync: Bool = false, success: @escaping (ChartsCollection) -> Void) {
let path = Bundle.main.bundleURL
.appendingPathComponent(Constants.dataDir)
.appendingPathComponent(type.rawValue)
.appendingPathComponent(Constants.overviewFilename)
ChartsDataManager().readChart(file: path, extraCopiesCount: extraCopiesCount, sync: sync, success: success, failure: { _ in })
}
static func detaildData(type: ChartsDataType, extraCopiesCount: Int = 0, date: Date, success: @escaping (ChartsCollection) -> Void, failure: @escaping (Error) -> Void) {
let dateComponents = Calendar.utc.dateComponents([.day, .month, .year], from: date)
let yearMonth = String(format: "%04d-%02d", dateComponents.year ?? 0, dateComponents.month ?? 0)
let day = String(format: "%02d.json", dateComponents.day ?? 0)
let path = Bundle.main.bundleURL
.appendingPathComponent(Constants.dataDir)
.appendingPathComponent(type.rawValue)
.appendingPathComponent(yearMonth)
.appendingPathComponent(day)
ChartsDataManager().readChart(file: path, extraCopiesCount: extraCopiesCount, sync: false, success: success, failure: failure)
}
}

View file

@ -0,0 +1,222 @@
//
// ChartsStackViewController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/13/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class ChartsStackViewController: UIViewController {
@IBOutlet private var stackView: UIStackView!
@IBOutlet private var scrollView: UIScrollView!
@IBOutlet private var psLabel: UILabel!
@IBOutlet private var ppsLabel: UILabel!
@IBOutlet private var animationButton: ChartVisibilityItemView!
private var sections: [ChartStackSection] = []
private var colorMode: ColorMode = .night
private var colorModeButton: UIBarButtonItem!
private var performFastAnimation: Bool = false
override func viewDidLoad() {
super.viewDidLoad()
title = "Statistics"
colorModeButton = UIBarButtonItem(title: colorMode.switchTitle, style: .plain, target: self, action: #selector(didTapSwitchColorMode))
navigationItem.rightBarButtonItem = colorModeButton
apply(colorMode: colorMode, animated: false)
self.navigationController?.navigationBar.barStyle = .black
self.navigationController?.navigationBar.isTranslucent = false
self.view.isUserInteractionEnabled = false
animationButton.backgroundColor = .clear
animationButton.tapClosure = { [weak self] in
guard let self = self else { return }
self.setSlowAnimationEnabled(!self.animationButton.isChecked)
}
self.setSlowAnimationEnabled(false)
loadChart1()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
DispatchQueue.main.async {
self.view.setNeedsUpdateConstraints()
self.view.setNeedsLayout()
}
}
func loadChart1() {
ChartsDataLoader.overviewData(type: .generalLines, sync: true, success: { collection in
let generalLinesChartController = GeneralLinesChartController(chartsCollection: collection)
self.addSection(controller: generalLinesChartController, title: "FOLLOWERS")
generalLinesChartController.getDetailsData = { date, completion in
ChartsDataLoader.detaildData(type: .generalLines, date: date, success: { collection in
completion(collection)
}, failure: { error in
completion(nil)
})
}
DispatchQueue.main.async {
self.loadChart2()
}
})
}
func loadChart2() {
ChartsDataLoader.overviewData(type: .twoAxisLines, success: { collection in
let twoAxisLinesChartController = TwoAxisLinesChartController(chartsCollection: collection)
self.addSection(controller: twoAxisLinesChartController, title: "INTERACTIONS")
twoAxisLinesChartController.getDetailsData = { date, completion in
ChartsDataLoader.detaildData(type: .twoAxisLines, date: date, success: { collection in
completion(collection)
}, failure: { error in
completion(nil)
})
}
DispatchQueue.main.async {
self.loadChart3()
}
})
}
func loadChart3() {
ChartsDataLoader.overviewData(type: .stackedBars, success: { collection in
let stackedBarsChartController = StackedBarsChartController(chartsCollection: collection)
self.addSection(controller: stackedBarsChartController, title: "FRUITS")
stackedBarsChartController.getDetailsData = { date, completion in
ChartsDataLoader.detaildData(type: .stackedBars, date: date, success: { collection in
completion(collection)
}, failure: { error in
completion(nil)
})
}
DispatchQueue.main.async {
self.loadChart4()
}
})
}
func loadChart4() {
ChartsDataLoader.overviewData(type: .dailyBars, success: { collection in
let dailyBarsChartController = DailyBarsChartController(chartsCollection: collection)
self.addSection(controller: dailyBarsChartController, title: "VIEWS")
dailyBarsChartController.getDetailsData = { date, completion in
ChartsDataLoader.detaildData(type: .dailyBars, date: date, success: { collection in
completion(collection)
}, failure: { error in
completion(nil)
})
}
DispatchQueue.main.async {
self.loadChart5()
}
})
}
func loadChart5() {
ChartsDataLoader.overviewData(type: .percentPie, success: { collection in
let percentPieChartController = PercentPieChartController(chartsCollection: collection)
self.addSection(controller: percentPieChartController, title: "MORE FRUITS")
self.finalizeChartsLoading()
})
}
func setSlowAnimationEnabled(_ isEnabled: Bool) {
animationButton.setChecked(isChecked: isEnabled, animated: true)
if isEnabled {
TimeInterval.animationDurationMultipler = 5
} else {
TimeInterval.animationDurationMultipler = 1
}
}
func finalizeChartsLoading() {
self.view.isUserInteractionEnabled = true
}
func addSection(controller: BaseChartController, title: String) {
let section = Bundle.main.loadNibNamed("ChartStackSection", owner: nil, options: nil)?.first as! ChartStackSection
section.frame = UIScreen.main.bounds
section.layoutIfNeeded()
section.setup(controller: controller, title: title)
section.apply(colorMode: colorMode, animated: false)
stackView.addArrangedSubview(section)
sections.append(section)
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return (colorMode == .day) ? .default : .lightContent
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return .fade
}
@objc private func didTapSwitchColorMode() {
self.colorMode = self.colorMode == .day ? .night : .day
apply(colorMode: self.colorMode, animated: !performFastAnimation)
colorModeButton.title = colorMode.switchTitle
}
}
extension ChartsStackViewController: UIScrollViewDelegate {
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
performFastAnimation = decelerate
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
performFastAnimation = false
}
}
extension ChartsStackViewController: ColorModeContainer {
func apply(colorMode: ColorMode, animated: Bool) {
UIView.perform(animated: animated) {
self.psLabel.setTextColor(colorMode.sectionTitleColor, animated: animated && self.psLabel.isVisibleInWindow)
self.ppsLabel.setTextColor(colorMode.sectionTitleColor, animated: animated && self.ppsLabel.isVisibleInWindow)
self.animationButton.item = ChartVisibilityItem(title: "Enable slow animations",
color: colorMode.sectionTitleColor)
self.view.backgroundColor = colorMode.tableBackgroundColor
if (animated) {
let animation = CATransition()
animation.timingFunction = CAMediaTimingFunction.init(name: .linear)
animation.type = .fade
animation.duration = .defaultDuration
self.navigationController?.navigationBar.layer.add(animation, forKey: "kCATransitionColorFade")
}
self.navigationController?.navigationBar.tintColor = colorMode.actionButtonColor
self.navigationController?.navigationBar.barTintColor = colorMode.chartBackgroundColor
self.navigationController?.navigationBar.titleTextAttributes = [.font: UIFont.systemFont(ofSize: 17, weight: .medium),
.foregroundColor: colorMode.viewTintColor]
self.view.layoutIfNeeded()
}
self.setNeedsStatusBarAppearanceUpdate()
for section in sections {
section.apply(colorMode: colorMode, animated: animated && section.isVisibleInWindow)
}
}
}
extension ColorMode {
var switchTitle: String {
switch self {
case .day:
return "Night Mode"
case .night:
return "Day Mode"
}
}
}

View file

@ -0,0 +1,291 @@
//
// RangeChartView.swift
// GraphTest
//
// Created by Andrei Salavei on 3/11/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
private enum Constants {
static let cropIndocatorLineWidth: CGFloat = 1
static let markerSelectionRange: CGFloat = 25
static let defaultMinimumRangeDistance: CGFloat = 0.05
static let titntAreaWidth: CGFloat = 10
static let horizontalContentMargin: CGFloat = 16
static let cornerRadius: CGFloat = 5
}
class RangeChartView: UIControl {
private enum Marker {
case lower
case upper
case center
}
public var lowerBound: CGFloat = 0 {
didSet {
setNeedsLayout()
}
}
public var upperBound: CGFloat = 1 {
didSet {
setNeedsLayout()
}
}
public var selectionColor: UIColor = .blue
public var defaultColor: UIColor = .lightGray
public var minimumRangeDistance: CGFloat = Constants.defaultMinimumRangeDistance
private let lowerBoundTintView = UIView()
private let upperBoundTintView = UIView()
private let cropFrameView = UIImageView()
private var selectedMarker: Marker?
private var selectedMarkerHorizontalOffet: CGFloat = 0
private var isBoundCropHighlighted: Bool = false
private var isRangePagingEnabled: Bool = false
public let chartView = ChartView()
override init(frame: CGRect) {
super.init(frame: frame)
layoutMargins = UIEdgeInsets(top: Constants.cropIndocatorLineWidth,
left: Constants.horizontalContentMargin,
bottom: Constants.cropIndocatorLineWidth,
right: Constants.horizontalContentMargin)
self.setup()
}
func setup() {
isMultipleTouchEnabled = false
chartView.chartInsets = .zero
chartView.backgroundColor = .clear
addSubview(chartView)
addSubview(lowerBoundTintView)
addSubview(upperBoundTintView)
addSubview(cropFrameView)
cropFrameView.isUserInteractionEnabled = false
chartView.isUserInteractionEnabled = false
lowerBoundTintView.isUserInteractionEnabled = false
upperBoundTintView.isUserInteractionEnabled = false
chartView.layer.cornerRadius = 5
upperBoundTintView.layer.cornerRadius = 5
lowerBoundTintView.layer.cornerRadius = 5
chartView.layer.masksToBounds = true
upperBoundTintView.layer.masksToBounds = true
lowerBoundTintView.layer.masksToBounds = true
layoutViews()
}
override func awakeFromNib() {
super.awakeFromNib()
self.setup()
}
public var rangeDidChangeClosure: ((ClosedRange<CGFloat>) -> Void)?
public var touchedOutsideClosure: (() -> Void)?
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
func setRangePaging(enabled: Bool, minimumSize: CGFloat) {
isRangePagingEnabled = enabled
minimumRangeDistance = minimumSize
}
func setRange(_ range: ClosedRange<CGFloat>, animated: Bool) {
UIView.perform(animated: animated) {
self.lowerBound = range.lowerBound
self.upperBound = range.upperBound
self.layoutIfNeeded()
}
}
override func layoutSubviews() {
super.layoutSubviews()
layoutViews()
}
override var isEnabled: Bool {
get {
return super.isEnabled
}
set {
if newValue == false {
selectedMarker = nil
}
super.isEnabled = newValue
}
}
// MARK: - Touches
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard isEnabled else { return }
guard let point = touches.first?.location(in: self) else { return }
if abs(locationInView(for: upperBound) - point.x + Constants.markerSelectionRange / 2) < Constants.markerSelectionRange {
selectedMarker = .upper
selectedMarkerHorizontalOffet = point.x - locationInView(for: upperBound)
isBoundCropHighlighted = true
} else if abs(locationInView(for: lowerBound) - point.x - Constants.markerSelectionRange / 2) < Constants.markerSelectionRange {
selectedMarker = .lower
selectedMarkerHorizontalOffet = point.x - locationInView(for: lowerBound)
isBoundCropHighlighted = true
} else if point.x > locationInView(for: lowerBound) && point.x < locationInView(for: upperBound) {
selectedMarker = .center
selectedMarkerHorizontalOffet = point.x - locationInView(for: lowerBound)
isBoundCropHighlighted = true
} else {
selectedMarker = nil
return
}
sendActions(for: .touchDown)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard isEnabled else { return }
guard let selectedMarker = selectedMarker else { return }
guard let point = touches.first?.location(in: self) else { return }
let horizontalPosition = point.x - selectedMarkerHorizontalOffet
let fraction = fractionFor(offsetX: horizontalPosition)
updateMarkerOffset(selectedMarker, fraction: fraction)
sendActions(for: .valueChanged)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard isEnabled else { return }
guard let selectedMarker = selectedMarker else {
touchedOutsideClosure?()
return
}
guard let point = touches.first?.location(in: self) else { return }
let horizontalPosition = point.x - selectedMarkerHorizontalOffet
let fraction = fractionFor(offsetX: horizontalPosition)
updateMarkerOffset(selectedMarker, fraction: fraction)
self.selectedMarker = nil
self.isBoundCropHighlighted = false
if bounds.contains(point) {
sendActions(for: .touchUpInside)
} else {
sendActions(for: .touchUpOutside)
}
rangeDidChangeClosure?(lowerBound...upperBound)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
self.selectedMarker = nil
self.isBoundCropHighlighted = false
sendActions(for: .touchCancel)
}
}
private extension RangeChartView {
var contentFrame: CGRect {
return CGRect(x: layoutMargins.right,
y: layoutMargins.top,
width: (bounds.width - layoutMargins.right - layoutMargins.left),
height: bounds.height - layoutMargins.top - layoutMargins.bottom)
}
func locationInView(for fraction: CGFloat) -> CGFloat {
return contentFrame.minX + contentFrame.width * fraction
}
func locationInView(for fraction: Double) -> CGFloat {
return locationInView(for: CGFloat(fraction))
}
func fractionFor(offsetX: CGFloat) -> CGFloat {
guard contentFrame.width > 0 else {
return 0
}
return crop(0, CGFloat((offsetX - contentFrame.minX ) / contentFrame.width), 1)
}
private func updateMarkerOffset(_ marker: Marker, fraction: CGFloat, notifyDelegate: Bool = true) {
let fractionToCount: CGFloat
if isRangePagingEnabled {
guard let minValue = stride(from: CGFloat(0.0), through: CGFloat(1.0), by: minimumRangeDistance).min(by: { abs($0 - fraction) < abs($1 - fraction) }) else { return }
fractionToCount = minValue
} else {
fractionToCount = fraction
}
switch marker {
case .lower:
lowerBound = min(fractionToCount, upperBound - minimumRangeDistance)
case .upper:
upperBound = max(fractionToCount, lowerBound + minimumRangeDistance)
case .center:
let distance = upperBound - lowerBound
lowerBound = max(0, min(fractionToCount, 1 - distance))
upperBound = lowerBound + distance
}
if notifyDelegate {
rangeDidChangeClosure?(lowerBound...upperBound)
}
UIView.animate(withDuration: isRangePagingEnabled ? 0.1 : 0) {
self.layoutIfNeeded()
}
}
// MARK: - Layout
func layoutViews() {
cropFrameView.frame = CGRect(x: locationInView(for: lowerBound),
y: contentFrame.minY - Constants.cropIndocatorLineWidth,
width: locationInView(for: upperBound) - locationInView(for: lowerBound),
height: contentFrame.height + Constants.cropIndocatorLineWidth * 2)
if chartView.frame != contentFrame {
chartView.frame = contentFrame
}
lowerBoundTintView.frame = CGRect(x: contentFrame.minX,
y: contentFrame.minY,
width: max(0, locationInView(for: lowerBound) - contentFrame.minX + Constants.titntAreaWidth),
height: contentFrame.height)
upperBoundTintView.frame = CGRect(x: locationInView(for: upperBound) - Constants.titntAreaWidth,
y: contentFrame.minY,
width: max(0, contentFrame.maxX - locationInView(for: upperBound) + Constants.titntAreaWidth),
height: contentFrame.height)
}
}
extension RangeChartView: ColorModeContainer {
func apply(colorMode: ColorMode, animated: Bool) {
let colusre = {
self.lowerBoundTintView.backgroundColor = colorMode.rangeViewTintColor
self.upperBoundTintView.backgroundColor = colorMode.rangeViewTintColor
}
self.cropFrameView.setImage(colorMode.rangeCropImage, animated: animated)
// self.chartView.apply(colorMode: colorMode, animated: animated)
if animated {
UIView.animate(withDuration: .defaultDuration, animations: colusre)
} else {
colusre()
}
}
}

View file

@ -0,0 +1,48 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import AppBundle
public final class ChartNode: ASDisplayNode {
private var chartView: ChartStackSection {
return self.view as! ChartStackSection
}
public override init() {
super.init()
self.setViewBlock({
return ChartStackSection()
})
}
public override func didLoad() {
super.didLoad()
self.view.disablesInteractiveTransitionGestureRecognizer = true
}
public func setup(_ data: String, bar: Bool = false) {
if let data = data.data(using: .utf8) {
ChartsDataManager().readChart(data: data, extraCopiesCount: 0, sync: true, success: { [weak self] collection in
let controller: BaseChartController
if bar {
controller = DailyBarsChartController(chartsCollection: collection)
} else {
controller = GeneralLinesChartController(chartsCollection: collection)
}
if let strongSelf = self {
strongSelf.chartView.setup(controller: controller, title: "")
strongSelf.chartView.apply(colorMode: .day, animated: false)
}
}) { error in
}
}
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View file

@ -0,0 +1,91 @@
//
// ChardData.swift
// GraphTest
//
// Created by Andrei Salavei on 3/11/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
import UIKit
struct ChartsCollection {
struct Chart {
var color: UIColor
var name: String
var values: [Double]
}
var axisValues: [Date]
var chartValues: [Chart]
static let blank = ChartsCollection(axisValues: [], chartValues: [])
var isBlank: Bool {
return axisValues.isEmpty || chartValues.isEmpty
}
}
extension ChartsCollection {
public init(from decodedData: [String: Any]) throws {
guard let columns = decodedData["columns"] as? [[Any]] else {
throw ChartsError.generalConversion("Unable to get columns from: \(decodedData)")
}
guard let types = decodedData["types"] as? [String: String] else {
throw ChartsError.generalConversion("Unable to get types from: \(decodedData)")
}
guard let names = decodedData["names"] as? [String: String] else {
throw ChartsError.generalConversion("Unable to get names from: \(decodedData)")
}
guard let colors = decodedData["colors"] as? [String: String] else {
throw ChartsError.generalConversion("Unable to get colors from: \(decodedData)")
}
// chart.colors Color for each variable in 6-hex-digit format (e.g. "#AAAAAA").
// chart.names Name for each variable.
// chart.percentage true for percentage based values.
// chart.stacked true for values stacking on top of each other.
// chart.y_scaled true for charts with 2 Y axes.
var axixValuesToSetup: [Date] = []
var chartToSetup: [Chart] = []
for column in columns {
guard let columnId = column.first as? String else {
throw ChartsError.generalConversion("Unable to get column name from: \(column)")
}
guard let typeString = types[columnId], let type = ColumnType(rawValue: typeString) else {
throw ChartsError.generalConversion("Unable to get column type from: \(types) - \(columnId)")
}
switch type {
case .axix:
axixValuesToSetup = try column.dropFirst().map { Date(timeIntervalSince1970: try Convert.doubleFrom($0) / 1000) }
case .chart, .bar, .area:
guard let colorString = colors[columnId],
let color = UIColor(hexString: colorString) else {
throw ChartsError.generalConversion("Unable to get color name from: \(colors) - \(columnId)")
}
guard let name = names[columnId] else {
throw ChartsError.generalConversion("Unable to get column name from: \(names) - \(columnId)")
}
let values = try column.dropFirst().map { try Convert.doubleFrom($0) }
chartToSetup.append(Chart(color: color,
name: name,
values: values))
}
}
guard axixValuesToSetup.isEmpty == false,
chartToSetup.isEmpty == false,
chartToSetup.firstIndex(where: { $0.values.count != axixValuesToSetup.count }) == nil else {
throw ChartsError.generalConversion("Saniazing: Invalid number of items: \(axixValuesToSetup), \(chartToSetup)")
}
self.axisValues = axixValuesToSetup
self.chartValues = chartToSetup
}
}
private enum ColumnType: String {
case axix = "x"
case chart = "line"
case area = "area"
case bar = "bar"
}

View file

@ -0,0 +1,191 @@
//
// ChartsDataManager.swift
// GraphTest
//
// Created by Andrei Salavei on 3/11/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
class ChartsDataManager {
func readChart(item: [String: Any], extraCopiesCount: Int = 0, sync: Bool, success: @escaping (ChartsCollection) -> Void, failure: @escaping (Error) -> Void) {
let workItem: (() -> Void) = {
do {
var collection = try ChartsCollection(from: item)
for _ in 0..<extraCopiesCount {
for valueIndex in collection.chartValues.indices {
collection.chartValues[valueIndex] .values += collection.chartValues[valueIndex].values
}
guard let firstValue = collection.axisValues.first,
let lastValule = collection.axisValues.last else {
throw ChartsError.invalidJson
}
let startItem = lastValule.addingTimeInterval(.day)
for valueIndex in collection.axisValues.indices {
let intervalToAdd = collection.axisValues[valueIndex].timeIntervalSince(firstValue)
let newDate = startItem.addingTimeInterval(intervalToAdd)
collection.axisValues.append(newDate)
}
}
if sync {
success(collection)
} else {
DispatchQueue.main.async {
success(collection)
}
}
} catch {
DispatchQueue.main.async {
assertionFailure("Error occure: \(error)")
failure(error)
}
}
}
if sync {
workItem()
} else {
DispatchQueue.global().async(execute: workItem)
}
}
func readChart(data: Data, extraCopiesCount: Int = 0, sync: Bool, success: @escaping (ChartsCollection) -> Void, failure: @escaping (Error) -> Void) {
let workItem: (() -> Void) = {
do {
let decoded = try JSONSerialization.jsonObject(with: data, options: [])
guard let item = decoded as? [String: Any] else {
throw ChartsError.invalidJson
}
var collection = try ChartsCollection(from: item)
for _ in 0..<extraCopiesCount {
for valueIndex in collection.chartValues.indices {
collection.chartValues[valueIndex] .values += collection.chartValues[valueIndex].values
}
guard let firstValue = collection.axisValues.first,
let lastValule = collection.axisValues.last else {
throw ChartsError.invalidJson
}
let startItem = lastValule.addingTimeInterval(.day)
for valueIndex in collection.axisValues.indices {
let intervalToAdd = collection.axisValues[valueIndex].timeIntervalSince(firstValue)
let newDate = startItem.addingTimeInterval(intervalToAdd)
collection.axisValues.append(newDate)
}
}
if sync {
success(collection)
} else {
DispatchQueue.main.async {
success(collection)
}
}
} catch {
DispatchQueue.main.async {
assertionFailure("Error occure: \(error)")
failure(error)
}
}
}
if sync {
workItem()
} else {
DispatchQueue.global().async(execute: workItem)
}
}
func readChart(file: URL, extraCopiesCount: Int = 0, sync: Bool, success: @escaping (ChartsCollection) -> Void, failure: @escaping (Error) -> Void) {
let workItem: (() -> Void) = {
do {
let data = try Data(contentsOf: file)
let decoded = try JSONSerialization.jsonObject(with: data, options: [])
guard let item = decoded as? [String: Any] else {
throw ChartsError.invalidJson
}
var collection = try ChartsCollection(from: item)
for _ in 0..<extraCopiesCount {
for valueIndex in collection.chartValues.indices {
collection.chartValues[valueIndex] .values += collection.chartValues[valueIndex].values
}
guard let firstValue = collection.axisValues.first,
let lastValule = collection.axisValues.last else {
throw ChartsError.invalidJson
}
let startItem = lastValule.addingTimeInterval(.day)
for valueIndex in collection.axisValues.indices {
let intervalToAdd = collection.axisValues[valueIndex].timeIntervalSince(firstValue)
let newDate = startItem.addingTimeInterval(intervalToAdd)
collection.axisValues.append(newDate)
}
}
if sync {
success(collection)
} else {
DispatchQueue.main.async {
success(collection)
}
}
} catch {
DispatchQueue.main.async {
assertionFailure("Error occure: \(error)")
failure(error)
}
}
}
if sync {
workItem()
} else {
DispatchQueue.global().async(execute: workItem)
}
}
func readCharts(file: URL, extraCopiesCount: Int = 0, sync: Bool, success: @escaping ([ChartsCollection]) -> Void, failure: @escaping (Error) -> Void) {
let workItem: (() -> Void) = {
do {
let data = try Data(contentsOf: file)
let decoded = try JSONSerialization.jsonObject(with: data, options: [])
guard let items = decoded as? [[String: Any]] else {
throw ChartsError.invalidJson
}
var collections = try items.map { try ChartsCollection(from: $0) }
for _ in 0..<extraCopiesCount {
for collrctionIndex in collections.indices {
for valueIndex in collections[collrctionIndex].chartValues.indices {
collections[collrctionIndex].chartValues[valueIndex] .values += collections[collrctionIndex].chartValues[valueIndex].values
}
guard let firstValue = collections[collrctionIndex].axisValues.first,
let lastValule = collections[collrctionIndex].axisValues.last else {
return
}
let startItem = lastValule.addingTimeInterval(.day)
for valueIndex in collections[collrctionIndex].axisValues.indices {
let intervalToAdd = collections[collrctionIndex].axisValues[valueIndex].timeIntervalSince(firstValue)
let newDate = startItem.addingTimeInterval(intervalToAdd)
collections[collrctionIndex].axisValues.append(newDate)
}
}
}
if sync {
success(collections)
} else {
DispatchQueue.main.async {
success(collections)
}
}
} catch {
DispatchQueue.main.async {
assertionFailure("Error occure: \(error)")
failure(error)
}
}
}
if sync {
workItem()
} else {
DispatchQueue.global().async(execute: workItem)
}
}
}

View file

@ -0,0 +1,14 @@
//
// ChartsError.swift
// GraphTest
//
// Created by Andrei Salavei on 3/11/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
enum ChartsError: Error {
case invalidJson
case generalConversion(String)
}

View file

@ -0,0 +1,42 @@
//
// Convert.swift
// GraphTest
//
// Created by Andrei Salavei on 3/11/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
public enum Convert {
public static func doubleFrom(_ value: Any?) throws -> Double {
guard let double = try doubleFrom(value, lenientCast: false) else {
throw ChartsError.generalConversion("Unable to cast \(String(describing: value)) to \(Double.self)")
}
return double
}
public static func doubleFrom(_ value: Any?, lenientCast: Bool = false) throws -> Double? {
guard let value = value else {
return nil
}
if let intValue = value as? Int {
return Double(intValue)
} else if let floatValue = value as? Float {
return Double(floatValue)
} else if let int64Value = value as? Int64 {
return Double(int64Value)
} else if let intValue = value as? Int {
return Double(intValue)
} else if let stringValue = value as? String {
if let doubleValue = Double(stringValue) {
return doubleValue
}
}
if lenientCast {
return nil
} else {
throw ChartsError.generalConversion("Unable to cast \(String(describing: value)) to \(Double.self)")
}
}
}

View file

@ -0,0 +1,19 @@
//
// StatisticsUI.h
// StatisticsUI
//
// Created by Peter on 8/13/19.
// Copyright © 2019 Telegram Messenger LLP. All rights reserved.
//
#import <UIKit/UIKit.h>
//! Project version number for StatisticsUI.
FOUNDATION_EXPORT double StatisticsUIVersionNumber;
//! Project version string for StatisticsUI.
FOUNDATION_EXPORT const unsigned char StatisticsUIVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <StatisticsUI/PublicHeader.h>

View file

@ -0,0 +1,166 @@
//
// BaseChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
enum BaseConstants {
static let defaultRange: ClosedRange<CGFloat> = 0...1
static let minimumAxisYLabelsDistance: CGFloat = 90
static let monthDayDateFormatter = DateFormatter.utc(format: "MMM d")
static let timeDateFormatter = DateFormatter.utc(format: "HH:mm")
static let headerFullRangeFormatter: DateFormatter = {
let formatter = DateFormatter.utc()
formatter.calendar = Calendar.utc
formatter.dateStyle = .long
return formatter
}()
static let headerMediumRangeFormatter: DateFormatter = {
let formatter = DateFormatter.utc()
formatter.dateStyle = .medium
return formatter
}()
static let headerFullZoomedFormatter: DateFormatter = {
let formatter = DateFormatter.utc()
formatter.dateStyle = .full
return formatter
}()
static let verticalBaseAnchors: [CGFloat] = [8, 5, 2.5, 2, 1]
static let defaultVerticalBaseAnchor: CGFloat = 1
static let mainChartLineWidth: CGFloat = 2
static let previewChartLineWidth: CGFloat = 1
static let previewLinesChartOptimizationLevel: CGFloat = 1.5
static let linesChartOptimizationLevel: CGFloat = 1.0
static let barsChartOptimizationLevel: CGFloat = 0.75
static let defaultRangePresetLength = TimeInterval.day * 60
static let chartNumberFormatter: ScalesNumberFormatter = {
let numberFormatter = ScalesNumberFormatter()
numberFormatter.allowsFloats = true
numberFormatter.numberStyle = .decimal
numberFormatter.usesGroupingSeparator = true
numberFormatter.groupingSeparator = " "
numberFormatter.minimumIntegerDigits = 1
numberFormatter.minimumFractionDigits = 0
numberFormatter.maximumFractionDigits = 2
return numberFormatter
}()
static let detailsNumberFormatter: NumberFormatter = {
let detailsNumberFormatter = NumberFormatter()
detailsNumberFormatter.allowsFloats = false
detailsNumberFormatter.numberStyle = .decimal
detailsNumberFormatter.usesGroupingSeparator = true
detailsNumberFormatter.groupingSeparator = " "
return detailsNumberFormatter
}()
}
class BaseChartController: ColorModeContainer {
//let performanceRenderer = PerformanceRenderer()
var initialChartsCollection: ChartsCollection
var isZoomed: Bool = false
var chartTitle: String = ""
init(chartsCollection: ChartsCollection) {
self.initialChartsCollection = chartsCollection
}
var mainChartRenderers: [ChartViewRenderer] {
fatalError("Abstract")
}
var navigationRenderers: [ChartViewRenderer] {
fatalError("Abstract")
}
var cartViewBounds: (() -> CGRect) = { fatalError() }
var chartFrame: (() -> CGRect) = { fatalError() }
func initializeChart() {
fatalError("Abstract")
}
func chartInteractionDidBegin(point: CGPoint) {
fatalError("Abstract")
}
func chartInteractionDidEnd() {
fatalError("Abstract")
}
func cancelChartInteraction() {
fatalError("Abstract")
}
func didTapZoomOut() {
fatalError("Abstract")
}
func updateChartsVisibility(visibility: [Bool], animated: Bool) {
fatalError("Abstract")
}
var currentHorizontalRange: ClosedRange<CGFloat> {
fatalError("Abstract")
}
var isChartRangePagingEnabled: Bool = false
var minimumSelectedChartRange: CGFloat = 0.05
var chartRangePagingClosure: ((Bool, CGFloat) -> Void)? // isEnabled, PageSize
func setChartRangePagingEnabled(isEnabled: Bool, minimumSelectionSize: CGFloat) {
isChartRangePagingEnabled = isEnabled
minimumSelectedChartRange = minimumSelectionSize
chartRangePagingClosure?(isChartRangePagingEnabled, minimumSelectedChartRange)
}
var chartRangeUpdatedClosure: ((ClosedRange<CGFloat>, Bool) -> Void)?
var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
fatalError("Abstract")
}
func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>) {
fatalError("Abstract")
}
var actualChartVisibility: [Bool] {
fatalError("Abstract")
}
var actualChartsCollection: ChartsCollection {
fatalError("Abstract")
}
var drawChartVisibity: Bool {
return true
}
var drawChartNavigation: Bool {
return true
}
var setDetailsViewPositionClosure: ((CGFloat) -> Void)?
var setDetailsChartVisibleClosure: ((Bool, Bool) -> Void)?
var setDetailsViewModel: ((ChartDetailsViewModel, Bool) -> Void)?
var getDetailsData: ((Date, @escaping (ChartsCollection?) -> Void) -> Void)?
var setChartTitleClosure: ((String, Bool) -> Void)?
var setBackButtonVisibilityClosure: ((Bool, Bool) -> Void)?
var refreshChartToolsClosure: ((Bool) -> Void)?
func didTapZoomIn(date: Date) {
fatalError("Abstract")
}
func apply(colorMode: ColorMode, animated: Bool) {
}
}

View file

@ -0,0 +1,328 @@
//
// GeneralChartComponentController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/11/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
enum GeneralChartComponentConstants {
static let defaultInitialRangeLength = CGFloat(TimeInterval.day * 60)
static let defaultZoomedRangeLength = CGFloat(TimeInterval.day)
}
class GeneralChartComponentController: ColorModeContainer {
var chartsCollection: ChartsCollection = ChartsCollection.blank
var chartVisibility: [Bool] = []
var lastChartInteractionPoint: CGPoint = .zero
var isChartInteractionBegun: Bool = false
var isChartInteracting: Bool = false
let isZoomed: Bool
var colorMode: ColorMode = .day
var totalHorizontalRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
var totalVerticalRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
var initialHorizontalRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
var initialVerticalRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
var cartViewBounds: (() -> CGRect) = { fatalError() }
var chartFrame: (() -> CGRect) = { fatalError() }
init(isZoomed: Bool) {
self.isZoomed = isZoomed
}
func initialize(chartsCollection: ChartsCollection,
initialDate: Date,
totalHorizontalRange: ClosedRange<CGFloat>,
totalVerticalRange: ClosedRange<CGFloat>) {
self.chartsCollection = chartsCollection
self.chartVisibility = Array(repeating: true, count: chartsCollection.chartValues.count)
self.totalHorizontalRange = totalHorizontalRange
self.totalVerticalRange = totalVerticalRange
self.initialHorizontalRange = totalHorizontalRange
self.initialVerticalRange = totalVerticalRange
didLoad()
setupInitialChartRange(initialDate: initialDate)
}
func didLoad() {
hideDetailsView(animated: false)
}
func willAppear(animated: Bool) {
updateChartRangeTitle(animated: animated)
setupChartRangePaging()
}
func willDisappear(animated: Bool) {
}
func setupInitialChartRange(initialDate: Date) {
guard let first = chartsCollection.axisValues.first?.timeIntervalSince1970,
let last = chartsCollection.axisValues.last?.timeIntervalSince1970 else { return }
let rangeStart = CGFloat(first)
let rangeEnd = CGFloat(last)
if isZoomed {
let initalDate = CGFloat(initialDate.timeIntervalSince1970)
initialHorizontalRange = max(initalDate, rangeStart)...min(initalDate + GeneralChartComponentConstants.defaultZoomedRangeLength, rangeEnd)
initialVerticalRange = totalVerticalRange
} else {
initialHorizontalRange = max(rangeStart, rangeEnd - GeneralChartComponentConstants.defaultInitialRangeLength)...rangeEnd
initialVerticalRange = totalVerticalRange
}
}
func setupChartRangePaging() {
chartRangePagingClosure?(false, 0.05)
}
var visibleHorizontalMainChartRange: ClosedRange<CGFloat> {
return currentMainRangeRenderer.verticalRange.current
}
var visibleVerticalMainChartRange: ClosedRange<CGFloat> {
return currentMainRangeRenderer.verticalRange.current
}
var currentHorizontalMainChartRange: ClosedRange<CGFloat> {
return currentMainRangeRenderer.horizontalRange.end
}
var currentVerticalMainChartRange: ClosedRange<CGFloat> {
return currentMainRangeRenderer.verticalRange.end
}
var currentMainRangeRenderer: BaseChartRenderer {
fatalError("Abstract")
}
var visiblePreviewHorizontalRange: ClosedRange<CGFloat> {
return currentPreviewRangeRenderer.verticalRange.current
}
var visiblePreviewVerticalRange: ClosedRange<CGFloat> {
return currentPreviewRangeRenderer.verticalRange.current
}
var currentPreviewHorizontalRange: ClosedRange<CGFloat> {
return currentPreviewRangeRenderer.horizontalRange.end
}
var currentPreviewVerticalRange: ClosedRange<CGFloat> {
return currentPreviewRangeRenderer.verticalRange.end
}
var currentPreviewRangeRenderer: BaseChartRenderer {
fatalError("Abstract")
}
var mainChartRenderers: [ChartViewRenderer] {
fatalError("Abstract")
}
var previewRenderers: [ChartViewRenderer] {
fatalError("Abstract")
}
func updateChartsVisibility(visibility: [Bool], animated: Bool) {
self.chartVisibility = visibility
if isChartInteractionBegun {
chartInteractionDidBegin(point: lastChartInteractionPoint)
}
}
var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
let lowerPercent = (currentHorizontalMainChartRange.lowerBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
let upperPercent = (currentHorizontalMainChartRange.upperBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
return lowerPercent...upperPercent
}
func chartRangeFractionDidUpdated(_ rangeFraction: ClosedRange<CGFloat>) {
let horizontalRange = ClosedRange(uncheckedBounds:
(lower: totalHorizontalRange.lowerBound + rangeFraction.lowerBound * totalHorizontalRange.distance,
upper: totalHorizontalRange.lowerBound + rangeFraction.upperBound * totalHorizontalRange.distance))
chartRangeDidUpdated(horizontalRange)
updateChartRangeTitle(animated: true)
}
func chartRangeDidUpdated(_ updatedRange: ClosedRange<CGFloat>) {
hideDetailsView(animated: true)
if isChartInteractionBegun {
chartInteractionDidBegin(point: lastChartInteractionPoint)
}
}
// MARK: - Details & Interaction
func findClosestDateTo(dateToFind: Date) -> (Date, Int)? {
guard chartsCollection.axisValues.count > 0 else { return nil }
var closestDate = chartsCollection.axisValues[0]
var minIndex = 0
for (index, date) in chartsCollection.axisValues.enumerated() {
if abs(dateToFind.timeIntervalSince(date)) < abs(dateToFind.timeIntervalSince(closestDate)) {
closestDate = date
minIndex = index
}
}
return (closestDate, minIndex)
}
func chartInteractionDidBegin(point: CGPoint) {
let chartFrame = self.chartFrame()
guard chartFrame.width > 0 else { return }
let horizontalRange = currentHorizontalMainChartRange
let dateToFind = Date(timeIntervalSince1970: TimeInterval(horizontalRange.distance * point.x + horizontalRange.lowerBound))
guard let (closestDate, minIndex) = findClosestDateTo(dateToFind: dateToFind) else { return }
let chartWasInteracting = isChartInteractionBegun
lastChartInteractionPoint = point
isChartInteractionBegun = true
isChartInteracting = true
let chartValue: CGFloat = CGFloat(closestDate.timeIntervalSince1970)
let detailsViewPosition = (chartValue - horizontalRange.lowerBound) / horizontalRange.distance * chartFrame.width + chartFrame.minX
showDetailsView(at: chartValue, detailsViewPosition: detailsViewPosition, dataIndex: minIndex, date: closestDate, animted: chartWasInteracting)
}
func showDetailsView(at chartPosition: CGFloat, detailsViewPosition: CGFloat, dataIndex: Int, date: Date, animted: Bool) {
setDetailsViewModel?(chartDetailsViewModel(closestDate: date, pointIndex: dataIndex), animted)
setDetailsChartVisibleClosure?(true, true)
setDetailsViewPositionClosure?(detailsViewPosition)
}
func chartInteractionDidEnd() {
isChartInteracting = false
}
func hideDetailsView(animated: Bool) {
isChartInteractionBegun = false
setDetailsChartVisibleClosure?(false, animated)
}
var visibleDetailsChartValues: [ChartsCollection.Chart] {
let visibleCharts: [ChartsCollection.Chart] = chartVisibility.enumerated().compactMap { args in
args.element ? chartsCollection.chartValues[args.offset] : nil
}
return visibleCharts
}
var updatePreviewRangeClosure: ((ClosedRange<CGFloat>, Bool) -> Void)?
var zoomInOnDateClosure: ((Date) -> Void)?
var setChartTitleClosure: ((String, Bool) -> Void)?
var setDetailsViewPositionClosure: ((CGFloat) -> Void)?
var setDetailsChartVisibleClosure: ((Bool, Bool) -> Void)?
var setDetailsViewModel: ((ChartDetailsViewModel, Bool) -> Void)?
var chartRangePagingClosure: ((Bool, CGFloat) -> Void)? // isEnabled, PageSize
func apply(colorMode: ColorMode, animated: Bool) {
self.colorMode = colorMode
}
// MARK: - Helpers
var prevoiusHorizontalStrideInterval: Int = -1
func updateHorizontalLimitLabels(horizontalScalesRenderer: HorizontalScalesRenderer,
horizontalRange: ClosedRange<CGFloat>,
scaleType: ChartScaleType,
forceUpdate: Bool,
animated: Bool) {
let scaleTimeInterval: TimeInterval
if chartsCollection.axisValues.count >= 1 {
scaleTimeInterval = chartsCollection.axisValues[1].timeIntervalSince1970 - chartsCollection.axisValues[0].timeIntervalSince1970
} else {
scaleTimeInterval = scaleType.timeInterval
}
let numberOfItems = horizontalRange.distance / CGFloat(scaleTimeInterval)
let maximumNumberOfItems = chartFrame().width / scaleType.minimumAxisXDistance
let tempStride = max(1, Int((numberOfItems / maximumNumberOfItems).rounded(.up)))
var strideInterval = 1
while strideInterval < tempStride {
strideInterval *= 2
}
if forceUpdate || (strideInterval != prevoiusHorizontalStrideInterval && strideInterval > 0) {
var labels: [LinesChartLabel] = []
for index in stride(from: chartsCollection.axisValues.count - 1, to: -1, by: -strideInterval).reversed() {
let date = chartsCollection.axisValues[index]
labels.append(LinesChartLabel(value: CGFloat(date.timeIntervalSince1970),
text: scaleType.dateFormatter.string(from: date)))
}
prevoiusHorizontalStrideInterval = strideInterval
horizontalScalesRenderer.setup(labels: labels, animated: animated)
}
}
func verticalLimitsLabels(verticalRange: ClosedRange<CGFloat>) -> (ClosedRange<CGFloat>, [LinesChartLabel]) {
let ditance = verticalRange.distance
let chartHeight = chartFrame().height
guard ditance > 0, chartHeight > 0 else { return (BaseConstants.defaultRange, []) }
let approximateNumberOfChartValues = (chartHeight / BaseConstants.minimumAxisYLabelsDistance)
var numberOfOffsetsPerItem = ditance / approximateNumberOfChartValues
var multiplier: CGFloat = 1.0
while numberOfOffsetsPerItem > 10 {
numberOfOffsetsPerItem /= 10
multiplier *= 10
}
var dividor: CGFloat = 1.0
var maximumNumberOfDecimals = 2
while numberOfOffsetsPerItem < 1 {
numberOfOffsetsPerItem *= 10
dividor *= 10
maximumNumberOfDecimals += 1
}
var base: CGFloat = BaseConstants.verticalBaseAnchors.first { numberOfOffsetsPerItem > $0 } ?? BaseConstants.defaultVerticalBaseAnchor
base = base * multiplier / dividor
var verticalLabels: [LinesChartLabel] = []
var verticalValue = (verticalRange.lowerBound / base).rounded(.down) * base
let lowerBound = verticalValue
let numberFormatter = BaseConstants.chartNumberFormatter
numberFormatter.maximumFractionDigits = maximumNumberOfDecimals
while verticalValue < verticalRange.upperBound {
let text: String = numberFormatter.string(from: NSNumber(value: Double(verticalValue))) ?? ""
verticalLabels.append(LinesChartLabel(value: verticalValue, text: text))
verticalValue += base
}
let updatedRange = lowerBound...verticalValue
return (updatedRange, verticalLabels)
}
func chartDetailsViewModel(closestDate: Date, pointIndex: Int) -> ChartDetailsViewModel {
let values: [ChartDetailsViewModel.Value] = chartsCollection.chartValues.enumerated().map { arg in
let (index, component) = arg
return ChartDetailsViewModel.Value(prefix: nil,
title: component.name,
value: BaseConstants.detailsNumberFormatter.string(from: NSNumber(value: component.values[pointIndex])) ?? "",
color: component.color,
visible: chartVisibility[index])
}
let dateString: String
if isZoomed {
dateString = BaseConstants.timeDateFormatter.string(from: closestDate)
} else {
dateString = BaseConstants.headerMediumRangeFormatter.string(from: closestDate)
}
let viewModel = ChartDetailsViewModel(title: dateString,
showArrow: !self.isZoomed,
showPrefixes: false,
values: values,
totalValue: nil,
tapAction: { [weak self] in
self?.zoomInOnDateClosure?(closestDate) })
return viewModel
}
func updateChartRangeTitle(animated: Bool) {
let fromDate = Date(timeIntervalSince1970: TimeInterval(currentHorizontalMainChartRange.lowerBound) + 1)
let toDate = Date(timeIntervalSince1970: TimeInterval(currentHorizontalMainChartRange.upperBound))
if Calendar.utc.startOfDay(for: fromDate) == Calendar.utc.startOfDay(for: toDate) {
let stirng = BaseConstants.headerFullZoomedFormatter.string(from: fromDate)
self.setChartTitleClosure?(stirng, animated)
} else {
let stirng = "\(BaseConstants.headerMediumRangeFormatter.string(from: fromDate)) - \(BaseConstants.headerMediumRangeFormatter.string(from: toDate))"
self.setChartTitleClosure?(stirng, animated)
}
}
}

View file

@ -0,0 +1,236 @@
//
// BaseLinesChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/14/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class BaseLinesChartController: BaseChartController {
var chartVisibility: [Bool]
var zoomChartVisibility: [Bool]
var lastChartInteractionPoint: CGPoint = .zero
var isChartInteractionBegun: Bool = false
var initialChartRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
var zoomedChartRange: ClosedRange<CGFloat> = BaseConstants.defaultRange
override init(chartsCollection: ChartsCollection) {
self.chartVisibility = Array(repeating: true, count: chartsCollection.chartValues.count)
self.zoomChartVisibility = []
super.init(chartsCollection: chartsCollection)
}
func setupChartCollection(chartsCollection: ChartsCollection, animated: Bool, isZoomed: Bool) {
if animated {
TimeInterval.setDefaultSuration(.expandAnimationDuration)
DispatchQueue.main.asyncAfter(deadline: .now() + .expandAnimationDuration) {
TimeInterval.setDefaultSuration(.osXDuration)
}
}
self.initialChartsCollection = chartsCollection
self.isZoomed = isZoomed
self.setBackButtonVisibilityClosure?(isZoomed, animated)
updateChartRangeTitle(animated: animated)
}
func updateChartRangeTitle(animated: Bool) {
let fromDate = Date(timeIntervalSince1970: TimeInterval(zoomedChartRange.lowerBound) + .hour)
let toDate = Date(timeIntervalSince1970: TimeInterval(zoomedChartRange.upperBound))
if Calendar.utc.startOfDay(for: fromDate) == Calendar.utc.startOfDay(for: toDate) {
let stirng = BaseConstants.headerFullZoomedFormatter.string(from: fromDate)
self.setChartTitleClosure?(stirng, animated)
} else {
let stirng = "\(BaseConstants.headerMediumRangeFormatter.string(from: fromDate)) - \(BaseConstants.headerMediumRangeFormatter.string(from: toDate))"
self.setChartTitleClosure?(stirng, animated)
}
}
override func chartInteractionDidBegin(point: CGPoint) {
lastChartInteractionPoint = point
isChartInteractionBegun = true
}
override func chartInteractionDidEnd() {
}
override func cancelChartInteraction() {
isChartInteractionBegun = false
}
override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>) {
}
override var actualChartVisibility: [Bool] {
return isZoomed ? zoomChartVisibility : chartVisibility
}
override var actualChartsCollection: ChartsCollection {
return initialChartsCollection
}
var visibleChartValues: [ChartsCollection.Chart] {
let visibleCharts: [ChartsCollection.Chart] = actualChartVisibility.enumerated().compactMap { args in
args.element ? initialChartsCollection.chartValues[args.offset] : nil
}
return visibleCharts
}
func chartDetailsViewModel(closestDate: Date, pointIndex: Int) -> ChartDetailsViewModel {
let values: [ChartDetailsViewModel.Value] = actualChartsCollection.chartValues.enumerated().map { arg in
let (index, component) = arg
return ChartDetailsViewModel.Value(prefix: nil,
title: component.name,
value: BaseConstants.detailsNumberFormatter.string(from: component.values[pointIndex]),
color: component.color,
visible: actualChartVisibility[index])
}
let dateString: String
if isZoomed {
dateString = BaseConstants.timeDateFormatter.string(from: closestDate)
} else {
dateString = BaseConstants.headerMediumRangeFormatter.string(from: closestDate)
}
let viewModel = ChartDetailsViewModel(title: dateString,
showArrow: !self.isZoomed,
showPrefixes: false,
values: values,
totalValue: nil,
tapAction: { [weak self] in self?.didTapZoomIn(date: closestDate) })
return viewModel
}
override func didTapZoomIn(date: Date) {
guard isZoomed == false else { return }
cancelChartInteraction()
self.getDetailsData?(date, { updatedCollection in
if let updatedCollection = updatedCollection {
self.initialChartRange = self.currentHorizontalRange
if let startDate = updatedCollection.axisValues.first,
let endDate = updatedCollection.axisValues.last {
self.zoomedChartRange = CGFloat(max(date.timeIntervalSince1970, startDate.timeIntervalSince1970))...CGFloat(min(date.timeIntervalSince1970 + .day - .hour, endDate.timeIntervalSince1970))
} else {
self.zoomedChartRange = CGFloat(date.timeIntervalSince1970)...CGFloat(date.timeIntervalSince1970 + .day - 1)
}
self.setupChartCollection(chartsCollection: updatedCollection, animated: true, isZoomed: true)
}
})
}
func horizontalLimitsLabels(horizontalRange: ClosedRange<CGFloat>,
scaleType: ChartScaleType,
prevoiusHorizontalStrideInterval: Int) -> (Int, [LinesChartLabel])? {
let numberOfItems = horizontalRange.distance / CGFloat(scaleType.timeInterval)
let maximumNumberOfItems = chartFrame().width / scaleType.minimumAxisXDistance
let tempStride = max(1, Int((numberOfItems / maximumNumberOfItems).rounded(.up)))
var strideInterval = 1
while strideInterval < tempStride {
strideInterval *= 2
}
if strideInterval != prevoiusHorizontalStrideInterval && strideInterval > 0 {
var labels: [LinesChartLabel] = []
for index in stride(from: initialChartsCollection.axisValues.count - 1, to: -1, by: -strideInterval).reversed() {
let date = initialChartsCollection.axisValues[index]
labels.append(LinesChartLabel(value: CGFloat(date.timeIntervalSince1970),
text: scaleType.dateFormatter.string(from: date)))
}
return (strideInterval, labels)
}
return nil
}
func findClosestDateTo(dateToFind: Date) -> (Date, Int)? {
guard initialChartsCollection.axisValues.count > 0 else { return nil }
var closestDate = initialChartsCollection.axisValues[0]
var minIndex = 0
for (index, date) in initialChartsCollection.axisValues.enumerated() {
if abs(dateToFind.timeIntervalSince(date)) < abs(dateToFind.timeIntervalSince(closestDate)) {
closestDate = date
minIndex = index
}
}
return (closestDate, minIndex)
}
func verticalLimitsLabels(verticalRange: ClosedRange<CGFloat>) -> (ClosedRange<CGFloat>, [LinesChartLabel]) {
let ditance = verticalRange.distance
let chartHeight = chartFrame().height
guard ditance > 0, chartHeight > 0 else { return (BaseConstants.defaultRange, []) }
let approximateNumberOfChartValues = (chartHeight / BaseConstants.minimumAxisYLabelsDistance)
var numberOfOffsetsPerItem = ditance / approximateNumberOfChartValues
var multiplier: CGFloat = 1.0
while numberOfOffsetsPerItem > 10 {
numberOfOffsetsPerItem /= 10
multiplier *= 10
}
var dividor: CGFloat = 1.0
var maximumNumberOfDecimals = 2
while numberOfOffsetsPerItem < 1 {
numberOfOffsetsPerItem *= 10
dividor *= 10
maximumNumberOfDecimals += 1
}
var base: CGFloat = BaseConstants.verticalBaseAnchors.first { numberOfOffsetsPerItem > $0 } ?? BaseConstants.defaultVerticalBaseAnchor
base = base * multiplier / dividor
var verticalLabels: [LinesChartLabel] = []
var verticalValue = (verticalRange.lowerBound / base).rounded(.down) * base
let lowerBound = verticalValue
let numberFormatter = BaseConstants.chartNumberFormatter
numberFormatter.maximumFractionDigits = maximumNumberOfDecimals
while verticalValue < verticalRange.upperBound {
let text: String = numberFormatter.string(from: NSNumber(value: Double(verticalValue))) ?? ""
verticalLabels.append(LinesChartLabel(value: verticalValue, text: text))
verticalValue += base
}
let updatedRange = lowerBound...verticalValue
return (updatedRange, verticalLabels)
}
}
enum ChartScaleType {
case day
case hour
case minutes5
}
extension ChartScaleType {
var timeInterval: TimeInterval {
switch self {
case .day: return .day
case .hour: return .hour
case .minutes5: return .minute * 5
}
}
var minimumAxisXDistance: CGFloat {
switch self {
case .day: return 50
case .hour: return 40
case .minutes5: return 40
}
}
var dateFormatter: DateFormatter {
switch self {
case .day: return BaseConstants.monthDayDateFormatter
case .hour: return BaseConstants.timeDateFormatter
case .minutes5: return BaseConstants.timeDateFormatter
}
}
}

View file

@ -0,0 +1,247 @@
//
// LinesChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
private enum Constants {
static let defaultRange: ClosedRange<CGFloat> = 0...1
}
class GeneralLinesChartController: BaseLinesChartController {
private let initialChartCollection: ChartsCollection
private let mainLinesRenderer = LinesChartRenderer()
private let horizontalScalesRenderer = HorizontalScalesRenderer()
private let verticalScalesRenderer = VerticalScalesRenderer()
private let verticalLineRenderer = VerticalLinesRenderer()
private let lineBulletsRenerer = LineBulletsRenerer()
private let previewLinesRenderer = LinesChartRenderer()
private var totalVerticalRange: ClosedRange<CGFloat> = Constants.defaultRange
private var totalHorizontalRange: ClosedRange<CGFloat> = Constants.defaultRange
private var prevoiusHorizontalStrideInterval: Int = 1
private (set) var chartLines: [LinesChartRenderer.LineData] = []
override init(chartsCollection: ChartsCollection) {
self.initialChartCollection = chartsCollection
self.mainLinesRenderer.lineWidth = 2
self.mainLinesRenderer.optimizationLevel = BaseConstants.linesChartOptimizationLevel
self.previewLinesRenderer.optimizationLevel = BaseConstants.previewLinesChartOptimizationLevel
self.lineBulletsRenerer.isEnabled = false
super.init(chartsCollection: chartsCollection)
self.zoomChartVisibility = chartVisibility
}
override func setupChartCollection(chartsCollection: ChartsCollection, animated: Bool, isZoomed: Bool) {
super.setupChartCollection(chartsCollection: chartsCollection, animated: animated, isZoomed: isZoomed)
self.chartLines = chartsCollection.chartValues.map { chart in
let points = chart.values.enumerated().map({ (arg) -> CGPoint in
return CGPoint(x: chartsCollection.axisValues[arg.offset].timeIntervalSince1970,
y: arg.element)
})
return LinesChartRenderer.LineData(color: chart.color, points: points)
}
self.prevoiusHorizontalStrideInterval = -1
self.totalVerticalRange = LinesChartRenderer.LineData.verticalRange(lines: chartLines) ?? Constants.defaultRange
self.totalHorizontalRange = LinesChartRenderer.LineData.horizontalRange(lines: chartLines) ?? Constants.defaultRange
self.lineBulletsRenerer.bullets = self.chartLines.map { LineBulletsRenerer.Bullet(coordinate: $0.points.first ?? .zero,
color: $0.color)}
let chartRange: ClosedRange<CGFloat>
if isZoomed {
chartRange = zoomedChartRange
} else {
chartRange = initialChartRange
}
self.previewLinesRenderer.setup(horizontalRange: totalHorizontalRange, animated: animated)
self.previewLinesRenderer.setup(verticalRange: totalVerticalRange, animated: animated)
self.mainLinesRenderer.setLines(lines: chartLines, animated: animated)
self.previewLinesRenderer.setLines(lines: chartLines, animated: animated)
updateHorizontalLimists(horizontalRange: chartRange, animated: animated)
updateMainChartHorizontalRange(range: chartRange, animated: animated)
updateVerticalLimitsAndRange(horizontalRange: chartRange, animated: animated)
self.chartRangeUpdatedClosure?(currentChartHorizontalRangeFraction, animated)
}
override func initializeChart() {
if let first = initialChartCollection.axisValues.first?.timeIntervalSince1970,
let last = initialChartCollection.axisValues.last?.timeIntervalSince1970 {
initialChartRange = CGFloat(max(first, last - BaseConstants.defaultRangePresetLength))...CGFloat(last)
}
setupChartCollection(chartsCollection: initialChartCollection, animated: false, isZoomed: false)
}
override var mainChartRenderers: [ChartViewRenderer] {
return [//performanceRenderer,
mainLinesRenderer,
horizontalScalesRenderer,
verticalScalesRenderer,
verticalLineRenderer,
lineBulletsRenerer
]
}
override var navigationRenderers: [ChartViewRenderer] {
return [previewLinesRenderer]
}
override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
chartVisibility = visibility
zoomChartVisibility = visibility
for (index, isVisible) in visibility.enumerated() {
mainLinesRenderer.setLineVisible(isVisible, at: index, animated: animated)
previewLinesRenderer.setLineVisible(isVisible, at: index, animated: animated)
lineBulletsRenerer.setLineVisible(isVisible, at: index, animated: animated)
}
updateVerticalLimitsAndRange(horizontalRange: currentHorizontalRange, animated: true)
if isChartInteractionBegun {
chartInteractionDidBegin(point: lastChartInteractionPoint)
}
}
override func chartInteractionDidBegin(point: CGPoint) {
let horizontalRange = mainLinesRenderer.horizontalRange.current
let chartFrame = self.chartFrame()
guard chartFrame.width > 0 else { return }
let chartInteractionWasBegin = isChartInteractionBegun
let dateToFind = Date(timeIntervalSince1970: TimeInterval(horizontalRange.distance * point.x + horizontalRange.lowerBound))
guard let (closestDate, minIndex) = findClosestDateTo(dateToFind: dateToFind) else { return }
super.chartInteractionDidBegin(point: point)
self.lineBulletsRenerer.bullets = chartLines.compactMap { chart in
return LineBulletsRenerer.Bullet(coordinate: chart.points[minIndex], color: chart.color)
}
self.lineBulletsRenerer.isEnabled = true
let chartValue: CGFloat = CGFloat(closestDate.timeIntervalSince1970)
let detailsViewPosition = (chartValue - horizontalRange.lowerBound) / horizontalRange.distance * chartFrame.width + chartFrame.minX
self.setDetailsViewModel?(chartDetailsViewModel(closestDate: closestDate, pointIndex: minIndex), chartInteractionWasBegin)
self.setDetailsChartVisibleClosure?(true, true)
self.setDetailsViewPositionClosure?(detailsViewPosition)
self.verticalLineRenderer.values = [chartValue]
}
override var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
let lowerPercent = (currentHorizontalRange.lowerBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
let upperPercent = (currentHorizontalRange.upperBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
return lowerPercent...upperPercent
}
override var currentHorizontalRange: ClosedRange<CGFloat> {
return mainLinesRenderer.horizontalRange.end
}
override func cancelChartInteraction() {
super.cancelChartInteraction()
self.lineBulletsRenerer.isEnabled = false
self.setDetailsChartVisibleClosure?(false, true)
self.verticalLineRenderer.values = []
}
override func didTapZoomOut() {
cancelChartInteraction()
self.setupChartCollection(chartsCollection: initialChartCollection, animated: true, isZoomed: false)
}
var visibleCharts: [LinesChartRenderer.LineData] {
let visibleCharts: [LinesChartRenderer.LineData] = chartVisibility.enumerated().compactMap { args in
args.element ? chartLines[args.offset] : nil
}
return visibleCharts
}
override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>) {
cancelChartInteraction()
let horizontalRange = ClosedRange(uncheckedBounds:
(lower: totalHorizontalRange.lowerBound + rangeFraction.lowerBound * totalHorizontalRange.distance,
upper: totalHorizontalRange.lowerBound + rangeFraction.upperBound * totalHorizontalRange.distance))
zoomedChartRange = horizontalRange
updateChartRangeTitle(animated: true)
updateMainChartHorizontalRange(range: horizontalRange, animated: false)
updateHorizontalLimists(horizontalRange: horizontalRange, animated: true)
updateVerticalLimitsAndRange(horizontalRange: horizontalRange, animated: true)
}
func updateMainChartHorizontalRange(range: ClosedRange<CGFloat>, animated: Bool) {
mainLinesRenderer.setup(horizontalRange: range, animated: animated)
horizontalScalesRenderer.setup(horizontalRange: range, animated: animated)
verticalScalesRenderer.setup(horizontalRange: range, animated: animated)
verticalLineRenderer.setup(horizontalRange: range, animated: animated)
lineBulletsRenerer.setup(horizontalRange: range, animated: animated)
}
func updateMainChartVerticalRange(range: ClosedRange<CGFloat>, animated: Bool) {
mainLinesRenderer.setup(verticalRange: range, animated: animated)
horizontalScalesRenderer.setup(verticalRange: range, animated: animated)
verticalScalesRenderer.setup(verticalRange: range, animated: animated)
verticalLineRenderer.setup(verticalRange: range, animated: animated)
lineBulletsRenerer.setup(verticalRange: range, animated: animated)
}
func updateHorizontalLimists(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
if let (stride, labels) = horizontalLimitsLabels(horizontalRange: horizontalRange,
scaleType: isZoomed ? .hour : .day,
prevoiusHorizontalStrideInterval: prevoiusHorizontalStrideInterval) {
self.horizontalScalesRenderer.setup(labels: labels, animated: animated)
self.prevoiusHorizontalStrideInterval = stride
}
}
func updateVerticalLimitsAndRange(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
if let verticalRange = LinesChartRenderer.LineData.verticalRange(lines: visibleCharts,
calculatingRange: horizontalRange,
addBounds: true) {
let (range, labels) = verticalLimitsLabels(verticalRange: verticalRange)
if verticalScalesRenderer.verticalRange.end != range {
verticalScalesRenderer.setup(verticalLimitsLabels: labels, animated: animated)
updateMainChartVerticalRange(range: range, animated: animated)
}
verticalScalesRenderer.setVisible(true, animated: animated)
} else {
verticalScalesRenderer.setVisible(false, animated: animated)
}
guard let previewVerticalRange = LinesChartRenderer.LineData.verticalRange(lines: visibleCharts) else { return }
if previewLinesRenderer.verticalRange.end != previewVerticalRange {
previewLinesRenderer.setup(verticalRange: previewVerticalRange, animated: animated)
}
}
override func apply(colorMode: ColorMode, animated: Bool) {
horizontalScalesRenderer.labelsColor = colorMode.chartLabelsColor
verticalScalesRenderer.labelsColor = colorMode.chartLabelsColor
verticalScalesRenderer.axisXColor = colorMode.chartStrongLinesColor
verticalScalesRenderer.horizontalLinesColor = colorMode.chartHelperLinesColor
lineBulletsRenerer.setInnerColor(colorMode.chartBackgroundColor, animated: animated)
verticalLineRenderer.linesColor = colorMode.chartStrongLinesColor
}
}

View file

@ -0,0 +1,306 @@
//
// TwoAxisLinesChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
private enum Constants {
static let verticalBaseAnchors: [CGFloat] = [8, 5, 4, 2.5, 2, 1]
static let defaultRange: ClosedRange<CGFloat> = 0...1
}
class TwoAxisLinesChartController: BaseLinesChartController {
class GraphController {
let mainLinesRenderer = LinesChartRenderer()
let verticalScalesRenderer = VerticalScalesRenderer()
let lineBulletsRenerer = LineBulletsRenerer()
let previewLinesRenderer = LinesChartRenderer()
var chartLines: [LinesChartRenderer.LineData] = []
var totalVerticalRange: ClosedRange<CGFloat> = Constants.defaultRange
init() {
self.mainLinesRenderer.lineWidth = 2
self.previewLinesRenderer.lineWidth = 1
self.lineBulletsRenerer.isEnabled = false
self.mainLinesRenderer.optimizationLevel = BaseConstants.linesChartOptimizationLevel
self.previewLinesRenderer.optimizationLevel = BaseConstants.previewLinesChartOptimizationLevel
}
func updateMainChartVerticalRange(range: ClosedRange<CGFloat>, animated: Bool) {
mainLinesRenderer.setup(verticalRange: range, animated: animated)
verticalScalesRenderer.setup(verticalRange: range, animated: animated)
lineBulletsRenerer.setup(verticalRange: range, animated: animated)
}
}
private var graphControllers: [GraphController] = []
private let verticalLineRenderer = VerticalLinesRenderer()
private let horizontalScalesRenderer = HorizontalScalesRenderer()
var totalHorizontalRange: ClosedRange<CGFloat> = Constants.defaultRange
private let initialChartCollection: ChartsCollection
private var prevoiusHorizontalStrideInterval: Int = 1
override init(chartsCollection: ChartsCollection) {
self.initialChartCollection = chartsCollection
graphControllers = chartsCollection.chartValues.map { _ in GraphController() }
super.init(chartsCollection: chartsCollection)
self.zoomChartVisibility = chartVisibility
}
override func setupChartCollection(chartsCollection: ChartsCollection, animated: Bool, isZoomed: Bool) {
super.setupChartCollection(chartsCollection: chartsCollection, animated: animated, isZoomed: isZoomed)
for (index, controller) in self.graphControllers.enumerated() {
let chart = chartsCollection.chartValues[index]
let points = chart.values.enumerated().map({ (arg) -> CGPoint in
return CGPoint(x: chartsCollection.axisValues[arg.offset].timeIntervalSince1970,
y: arg.element)
})
let chartLines = [LinesChartRenderer.LineData(color: chart.color, points: points)]
controller.chartLines = [LinesChartRenderer.LineData(color: chart.color, points: points)]
controller.verticalScalesRenderer.labelsColor = chart.color
controller.totalVerticalRange = LinesChartRenderer.LineData.verticalRange(lines: chartLines) ?? Constants.defaultRange
self.totalHorizontalRange = LinesChartRenderer.LineData.horizontalRange(lines: chartLines) ?? Constants.defaultRange
controller.lineBulletsRenerer.bullets = chartLines.map { LineBulletsRenerer.Bullet(coordinate: $0.points.first ?? .zero,
color: $0.color) }
controller.previewLinesRenderer.setup(horizontalRange: self.totalHorizontalRange, animated: animated)
controller.previewLinesRenderer.setup(verticalRange: controller.totalVerticalRange, animated: animated)
controller.mainLinesRenderer.setLines(lines: chartLines, animated: animated)
controller.previewLinesRenderer.setLines(lines: chartLines, animated: animated)
controller.verticalScalesRenderer.setHorizontalLinesVisible((index == 0), animated: animated)
controller.verticalScalesRenderer.isRightAligned = (index != 0)
}
self.prevoiusHorizontalStrideInterval = -1
let chartRange: ClosedRange<CGFloat>
if isZoomed {
chartRange = zoomedChartRange
} else {
chartRange = initialChartRange
}
updateHorizontalLimists(horizontalRange: chartRange, animated: animated)
updateMainChartHorizontalRange(range: chartRange, animated: animated)
updateVerticalLimitsAndRange(horizontalRange: chartRange, animated: animated)
self.chartRangeUpdatedClosure?(currentChartHorizontalRangeFraction, animated)
}
override func initializeChart() {
if let first = initialChartCollection.axisValues.first?.timeIntervalSince1970,
let last = initialChartCollection.axisValues.last?.timeIntervalSince1970 {
initialChartRange = CGFloat(max(first, last - BaseConstants.defaultRangePresetLength))...CGFloat(last)
}
setupChartCollection(chartsCollection: initialChartCollection, animated: false, isZoomed: false)
}
override var mainChartRenderers: [ChartViewRenderer] {
return graphControllers.map { $0.mainLinesRenderer } +
graphControllers.flatMap { [$0.verticalScalesRenderer, $0.lineBulletsRenerer] } +
[horizontalScalesRenderer, verticalLineRenderer,
// performanceRenderer
]
}
override var navigationRenderers: [ChartViewRenderer] {
return graphControllers.map { $0.previewLinesRenderer }
}
override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
chartVisibility = visibility
zoomChartVisibility = visibility
let firstIndex = visibility.firstIndex(where: { $0 })
for (index, isVisible) in visibility.enumerated() {
let graph = graphControllers[index]
for graphIndex in graph.chartLines.indices {
graph.mainLinesRenderer.setLineVisible(isVisible, at: graphIndex, animated: animated)
graph.previewLinesRenderer.setLineVisible(isVisible, at: graphIndex, animated: animated)
graph.lineBulletsRenerer.setLineVisible(isVisible, at: graphIndex, animated: animated)
}
graph.verticalScalesRenderer.setVisible(isVisible, animated: animated)
if let firstIndex = firstIndex {
graph.verticalScalesRenderer.setHorizontalLinesVisible(index == firstIndex, animated: animated)
}
}
updateVerticalLimitsAndRange(horizontalRange: currentHorizontalRange, animated: true)
if isChartInteractionBegun {
chartInteractionDidBegin(point: lastChartInteractionPoint)
}
}
override func chartInteractionDidBegin(point: CGPoint) {
let horizontalRange = currentHorizontalRange
let chartFrame = self.chartFrame()
guard chartFrame.width > 0 else { return }
let dateToFind = Date(timeIntervalSince1970: TimeInterval(horizontalRange.distance * point.x + horizontalRange.lowerBound))
guard let (closestDate, minIndex) = findClosestDateTo(dateToFind: dateToFind) else { return }
let chartInteractionWasBegin = isChartInteractionBegun
super.chartInteractionDidBegin(point: point)
for graphController in graphControllers {
graphController.lineBulletsRenerer.bullets = graphController.chartLines.map { chart in
LineBulletsRenerer.Bullet(coordinate: chart.points[minIndex], color: chart.color)
}
graphController.lineBulletsRenerer.isEnabled = true
}
let chartValue: CGFloat = CGFloat(closestDate.timeIntervalSince1970)
let detailsViewPosition = (chartValue - horizontalRange.lowerBound) / horizontalRange.distance * chartFrame.width + chartFrame.minX
self.setDetailsViewModel?(chartDetailsViewModel(closestDate: closestDate, pointIndex: minIndex), chartInteractionWasBegin)
self.setDetailsChartVisibleClosure?(true, true)
self.setDetailsViewPositionClosure?(detailsViewPosition)
self.verticalLineRenderer.values = [chartValue]
}
override var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
let lowerPercent = (currentHorizontalRange.lowerBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
let upperPercent = (currentHorizontalRange.upperBound - totalHorizontalRange.lowerBound) / totalHorizontalRange.distance
return lowerPercent...upperPercent
}
override var currentHorizontalRange: ClosedRange<CGFloat> {
return graphControllers.first?.mainLinesRenderer.horizontalRange.end ?? Constants.defaultRange
}
override func cancelChartInteraction() {
super.cancelChartInteraction()
for graphController in graphControllers {
graphController.lineBulletsRenerer.isEnabled = false
}
self.setDetailsChartVisibleClosure?(false, true)
self.verticalLineRenderer.values = []
}
override func didTapZoomOut() {
cancelChartInteraction()
self.setupChartCollection(chartsCollection: initialChartCollection, animated: true, isZoomed: false)
}
override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>) {
cancelChartInteraction()
let horizontalRange = ClosedRange(uncheckedBounds:
(lower: totalHorizontalRange.lowerBound + rangeFraction.lowerBound * totalHorizontalRange.distance,
upper: totalHorizontalRange.lowerBound + rangeFraction.upperBound * totalHorizontalRange.distance))
zoomedChartRange = horizontalRange
updateChartRangeTitle(animated: true)
updateMainChartHorizontalRange(range: horizontalRange, animated: false)
updateHorizontalLimists(horizontalRange: horizontalRange, animated: true)
updateVerticalLimitsAndRange(horizontalRange: horizontalRange, animated: true)
}
func updateMainChartHorizontalRange(range: ClosedRange<CGFloat>, animated: Bool) {
for controller in graphControllers {
controller.mainLinesRenderer.setup(horizontalRange: range, animated: animated)
controller.verticalScalesRenderer.setup(horizontalRange: range, animated: animated)
controller.lineBulletsRenerer.setup(horizontalRange: range, animated: animated)
}
horizontalScalesRenderer.setup(horizontalRange: range, animated: animated)
verticalLineRenderer.setup(horizontalRange: range, animated: animated)
}
func updateHorizontalLimists(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
if let (stride, labels) = horizontalLimitsLabels(horizontalRange: horizontalRange,
scaleType: isZoomed ? .hour : .day,
prevoiusHorizontalStrideInterval: prevoiusHorizontalStrideInterval) {
self.horizontalScalesRenderer.setup(labels: labels, animated: animated)
self.prevoiusHorizontalStrideInterval = stride
}
}
func updateVerticalLimitsAndRange(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
let chartHeight = chartFrame().height
let approximateNumberOfChartValues = (chartHeight / BaseConstants.minimumAxisYLabelsDistance)
var dividorsAndMultiplers: [(startValue: CGFloat, base: CGFloat, count: Int, maximumNumberOfDecimals: Int)] = graphControllers.enumerated().map { arg in
let (index, controller) = arg
let verticalRange = LinesChartRenderer.LineData.verticalRange(lines: controller.chartLines,
calculatingRange: horizontalRange,
addBounds: true) ?? controller.totalVerticalRange
var numberOfOffsetsPerItem = verticalRange.distance / approximateNumberOfChartValues
var multiplier: CGFloat = 1.0
while numberOfOffsetsPerItem > 10 {
numberOfOffsetsPerItem /= 10
multiplier *= 10
}
var dividor: CGFloat = 1.0
var maximumNumberOfDecimals = 2
while numberOfOffsetsPerItem < 1 {
numberOfOffsetsPerItem *= 10
dividor *= 10
maximumNumberOfDecimals += 1
}
let generalBase = Constants.verticalBaseAnchors.first { numberOfOffsetsPerItem > $0 } ?? BaseConstants.defaultVerticalBaseAnchor
let base = generalBase * multiplier / dividor
var verticalValue = (verticalRange.lowerBound / base).rounded(.down) * base
let startValue = verticalValue
var count = 0
if chartVisibility[index] {
while verticalValue < verticalRange.upperBound {
count += 1
verticalValue += base
}
}
return (startValue: startValue, base: base, count: count, maximumNumberOfDecimals: maximumNumberOfDecimals)
}
let totalCount = dividorsAndMultiplers.map { $0.count }.max() ?? 0
guard totalCount > 0 else { return }
let numberFormatter = BaseConstants.chartNumberFormatter
for (index, controller) in graphControllers.enumerated() {
let (startValue, base, _, maximumNumberOfDecimals) = dividorsAndMultiplers[index]
let updatedRange = startValue...(startValue + base * CGFloat(totalCount))
if controller.verticalScalesRenderer.verticalRange.end != updatedRange {
numberFormatter.maximumFractionDigits = maximumNumberOfDecimals
var verticalLabels: [LinesChartLabel] = []
for multipler in 0...(totalCount - 1) {
let verticalValue = startValue + base * CGFloat(multipler)
let text: String = numberFormatter.string(from: NSNumber(value: Double(verticalValue))) ?? ""
verticalLabels.append(LinesChartLabel(value: verticalValue, text: text))
}
controller.verticalScalesRenderer.setup(verticalLimitsLabels: verticalLabels, animated: animated)
controller.updateMainChartVerticalRange(range: updatedRange, animated: animated)
}
}
}
override func apply(colorMode: ColorMode, animated: Bool) {
horizontalScalesRenderer.labelsColor = colorMode.chartLabelsColor
verticalLineRenderer.linesColor = colorMode.chartStrongLinesColor
for controller in graphControllers {
controller.verticalScalesRenderer.horizontalLinesColor = colorMode.chartHelperLinesColor
controller.lineBulletsRenerer.setInnerColor(colorMode.chartBackgroundColor, animated: animated)
controller.verticalScalesRenderer.axisXColor = colorMode.chartStrongLinesColor
}
}
}

View file

@ -0,0 +1,195 @@
//
// PercentChartComponentController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/14/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class PercentChartComponentController: GeneralChartComponentController {
let mainPecentChartRenderer: PecentChartRenderer
let horizontalScalesRenderer: HorizontalScalesRenderer
let verticalScalesRenderer: VerticalScalesRenderer
let verticalLineRenderer: VerticalLinesRenderer
let previewPercentChartRenderer: PecentChartRenderer
var percentageData: PecentChartRenderer.PercentageData = .blank
init(isZoomed: Bool,
mainPecentChartRenderer: PecentChartRenderer,
horizontalScalesRenderer: HorizontalScalesRenderer,
verticalScalesRenderer: VerticalScalesRenderer,
verticalLineRenderer: VerticalLinesRenderer,
previewPercentChartRenderer: PecentChartRenderer) {
self.mainPecentChartRenderer = mainPecentChartRenderer
self.horizontalScalesRenderer = horizontalScalesRenderer
self.verticalScalesRenderer = verticalScalesRenderer
self.verticalLineRenderer = verticalLineRenderer
self.previewPercentChartRenderer = previewPercentChartRenderer
super.init(isZoomed: isZoomed)
}
override func initialize(chartsCollection: ChartsCollection, initialDate: Date, totalHorizontalRange _: ClosedRange<CGFloat>, totalVerticalRange _: ClosedRange<CGFloat>) {
let components = chartsCollection.chartValues.map { PecentChartRenderer.PercentageData.Component(color: $0.color,
values: $0.values.map { CGFloat($0) }) }
self.percentageData = PecentChartRenderer.PercentageData(locations: chartsCollection.axisValues.map { CGFloat($0.timeIntervalSince1970) },
components: components)
let totalHorizontalRange = PecentChartRenderer.PercentageData.horizontalRange(data: self.percentageData) ?? BaseConstants.defaultRange
let totalVerticalRange = BaseConstants.defaultRange
super.initialize(chartsCollection: chartsCollection,
initialDate: initialDate,
totalHorizontalRange: totalHorizontalRange,
totalVerticalRange: totalVerticalRange)
mainPecentChartRenderer.percentageData = self.percentageData
previewPercentChartRenderer.percentageData = self.percentageData
let axisValues: [CGFloat] = [0, 25, 50, 75, 100]
let labels: [LinesChartLabel] = axisValues.map { value in
return LinesChartLabel(value: value / 100, text: BaseConstants.detailsNumberFormatter.string(from: NSNumber(value: Double(value))) ?? "")
}
verticalScalesRenderer.setup(verticalLimitsLabels: labels, animated: false)
setupMainChart(horizontalRange: initialHorizontalRange, animated: false)
setupMainChart(verticalRange: initialVerticalRange, animated: false)
previewPercentChartRenderer.setup(verticalRange: totalVerticalRange, animated: false)
previewPercentChartRenderer.setup(horizontalRange: totalHorizontalRange, animated: false)
updateHorizontalLimitLabels(animated: false)
}
override func willAppear(animated: Bool) {
previewPercentChartRenderer.setup(verticalRange: totalVerticalRange, animated: animated)
previewPercentChartRenderer.setup(horizontalRange: totalHorizontalRange, animated: animated)
setConponentsVisible(visible: true, animated: true)
setupMainChart(verticalRange: initialVerticalRange, animated: animated)
setupMainChart(horizontalRange: initialHorizontalRange, animated: animated)
updatePreviewRangeClosure?(currentChartHorizontalRangeFraction, animated)
super.willAppear(animated: animated)
}
override func chartRangeDidUpdated(_ updatedRange: ClosedRange<CGFloat>) {
super.chartRangeDidUpdated(updatedRange)
initialHorizontalRange = updatedRange
setupMainChart(horizontalRange: updatedRange, animated: false)
updateHorizontalLimitLabels(animated: true)
}
func updateHorizontalLimitLabels(animated: Bool) {
updateHorizontalLimitLabels(horizontalScalesRenderer: horizontalScalesRenderer,
horizontalRange: initialHorizontalRange,
scaleType: isZoomed ? .hour : .day,
forceUpdate: false,
animated: animated)
}
func prepareAppearanceAnimation(horizontalRnage: ClosedRange<CGFloat>) {
setupMainChart(horizontalRange: horizontalRnage, animated: false)
setConponentsVisible(visible: false, animated: false)
}
func setConponentsVisible(visible: Bool, animated: Bool) {
mainPecentChartRenderer.setVisible(visible, animated: animated)
horizontalScalesRenderer.setVisible(visible, animated: animated)
verticalScalesRenderer.setVisible(visible, animated: animated)
verticalLineRenderer.setVisible(visible, animated: animated)
previewPercentChartRenderer.setVisible(visible, animated: animated)
}
func setupMainChart(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
mainPecentChartRenderer.setup(horizontalRange: horizontalRange, animated: animated)
horizontalScalesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
verticalScalesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
verticalLineRenderer.setup(horizontalRange: horizontalRange, animated: animated)
}
func setupMainChart(verticalRange: ClosedRange<CGFloat>, animated: Bool) {
mainPecentChartRenderer.setup(verticalRange: verticalRange, animated: animated)
horizontalScalesRenderer.setup(verticalRange: verticalRange, animated: animated)
verticalScalesRenderer.setup(verticalRange: verticalRange, animated: animated)
verticalLineRenderer.setup(verticalRange: verticalRange, animated: animated)
}
override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
super.updateChartsVisibility(visibility: visibility, animated: animated)
for (index, isVisible) in visibility.enumerated() {
mainPecentChartRenderer.setComponentVisible(isVisible, at: index, animated: animated)
previewPercentChartRenderer.setComponentVisible(isVisible, at: index, animated: animated)
}
verticalScalesRenderer.setVisible(visibility.contains(true), animated: animated)
}
override func chartDetailsViewModel(closestDate: Date, pointIndex: Int) -> ChartDetailsViewModel {
let visibleValues = visibleDetailsChartValues
let total = visibleValues.map { $0.values[pointIndex] }.reduce(0, +)
let values: [ChartDetailsViewModel.Value] = chartsCollection.chartValues.enumerated().map { arg in
let (index, component) = arg
return ChartDetailsViewModel.Value(prefix: PercentConstants.percentValueFormatter.string(from: component.values[pointIndex] / total * 100),
title: component.name,
value: BaseConstants.detailsNumberFormatter.string(from: component.values[pointIndex]),
color: component.color,
visible: chartVisibility[index])
}
let dateString: String
if isZoomed {
dateString = BaseConstants.timeDateFormatter.string(from: closestDate)
} else {
dateString = BaseConstants.headerMediumRangeFormatter.string(from: closestDate)
}
let viewModel = ChartDetailsViewModel(title: dateString,
showArrow: !self.isZoomed,
showPrefixes: true,
values: values,
totalValue: nil,
tapAction: { [weak self] in
self?.hideDetailsView(animated: true)
self?.zoomInOnDateClosure?(closestDate) })
return viewModel
}
var currentlyVisiblePercentageData: PecentChartRenderer.PercentageData {
var currentPercentageData = percentageData
currentPercentageData.components = chartVisibility.enumerated().compactMap { $0.element ? currentPercentageData.components[$0.offset] : nil }
return currentPercentageData
}
override var currentMainRangeRenderer: BaseChartRenderer {
return mainPecentChartRenderer
}
override var currentPreviewRangeRenderer: BaseChartRenderer {
return previewPercentChartRenderer
}
override func showDetailsView(at chartPosition: CGFloat, detailsViewPosition: CGFloat, dataIndex: Int, date: Date, animted: Bool) {
super.showDetailsView(at: chartPosition, detailsViewPosition: detailsViewPosition, dataIndex: dataIndex, date: date, animted: animted)
verticalLineRenderer.values = [chartPosition]
verticalLineRenderer.isEnabled = true
}
override func hideDetailsView(animated: Bool) {
super.hideDetailsView(animated: animated)
verticalLineRenderer.values = []
verticalLineRenderer.isEnabled = false
}
override func apply(colorMode: ColorMode, animated: Bool) {
super.apply(colorMode: colorMode, animated: animated)
horizontalScalesRenderer.labelsColor = colorMode.chartLabelsColor
verticalScalesRenderer.labelsColor = colorMode.chartLabelsColor
verticalScalesRenderer.axisXColor = colorMode.barChartStrongLinesColor
verticalScalesRenderer.horizontalLinesColor = colorMode.barChartStrongLinesColor
verticalLineRenderer.linesColor = colorMode.chartStrongLinesColor
}
}

View file

@ -0,0 +1,281 @@
//
// PercentPieChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
enum PercentConstants {
static let percentValueFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.positiveSuffix = "%"
return formatter
}()
}
private enum Constants {
static let zoomedRange = 7
}
class PercentPieChartController: BaseChartController {
let percentController: PercentChartComponentController
let pieController: PieChartComponentController
let transitionRenderer: PercentPieAnimationRenderer
override init(chartsCollection: ChartsCollection) {
transitionRenderer = PercentPieAnimationRenderer()
percentController = PercentChartComponentController(isZoomed: false,
mainPecentChartRenderer: PecentChartRenderer(),
horizontalScalesRenderer: HorizontalScalesRenderer(),
verticalScalesRenderer: VerticalScalesRenderer(),
verticalLineRenderer: VerticalLinesRenderer(),
previewPercentChartRenderer: PecentChartRenderer())
pieController = PieChartComponentController(isZoomed: true,
pieChartRenderer: PieChartRenderer(),
previewBarChartRenderer: BarChartRenderer())
super.init(chartsCollection: chartsCollection)
[percentController, pieController].forEach { controller in
controller.chartFrame = { [unowned self] in self.chartFrame() }
controller.cartViewBounds = { [unowned self] in self.cartViewBounds() }
controller.zoomInOnDateClosure = { [unowned self] date in
self.didTapZoomIn(date: date)
}
controller.setChartTitleClosure = { [unowned self] (title, animated) in
self.setChartTitleClosure?(title, animated)
}
controller.setDetailsViewPositionClosure = { [unowned self] (position) in
self.setDetailsViewPositionClosure?(position)
}
controller.setDetailsChartVisibleClosure = { [unowned self] (visible, animated) in
self.setDetailsChartVisibleClosure?(visible, animated)
}
controller.setDetailsViewModel = { [unowned self] (viewModel, animated) in
self.setDetailsViewModel?(viewModel, animated)
}
controller.updatePreviewRangeClosure = { [unowned self] (fraction, animated) in
self.chartRangeUpdatedClosure?(fraction, animated)
}
controller.chartRangePagingClosure = { [unowned self] (isEnabled, pageSize) in
self.setChartRangePagingEnabled(isEnabled: isEnabled, minimumSelectionSize: pageSize)
}
}
transitionRenderer.isEnabled = false
}
override var mainChartRenderers: [ChartViewRenderer] {
return [percentController.mainPecentChartRenderer,
transitionRenderer,
percentController.horizontalScalesRenderer,
percentController.verticalScalesRenderer,
percentController.verticalLineRenderer,
pieController.pieChartRenderer,
// performanceRenderer
]
}
override var navigationRenderers: [ChartViewRenderer] {
return [percentController.previewPercentChartRenderer,
pieController.previewBarChartRenderer]
}
override func initializeChart() {
percentController.initialize(chartsCollection: initialChartsCollection,
initialDate: Date(),
totalHorizontalRange: BaseConstants.defaultRange,
totalVerticalRange: BaseConstants.defaultRange)
switchToChart(chartsCollection: percentController.chartsCollection, isZoomed: false, animated: false)
}
func switchToChart(chartsCollection: ChartsCollection, isZoomed: Bool, animated: Bool) {
if animated {
TimeInterval.setDefaultSuration(.expandAnimationDuration)
DispatchQueue.main.asyncAfter(deadline: .now() + .expandAnimationDuration) {
TimeInterval.setDefaultSuration(.osXDuration)
}
}
super.isZoomed = isZoomed
if isZoomed {
let toHorizontalRange = pieController.initialHorizontalRange
pieController.updateChartsVisibility(visibility: percentController.chartVisibility, animated: false)
pieController.pieChartRenderer.setup(horizontalRange: percentController.currentHorizontalMainChartRange, animated: false)
pieController.previewBarChartRenderer.setup(horizontalRange: percentController.currentPreviewHorizontalRange, animated: false)
pieController.pieChartRenderer.setVisible(false, animated: false)
pieController.previewBarChartRenderer.setVisible(true, animated: false)
pieController.willAppear(animated: animated)
percentController.willDisappear(animated: animated)
pieController.pieChartRenderer.drawPie = false
percentController.mainPecentChartRenderer.isEnabled = false
setupTransitionRenderer()
percentController.setupMainChart(horizontalRange: toHorizontalRange, animated: animated)
percentController.previewPercentChartRenderer.setup(horizontalRange: toHorizontalRange, animated: animated)
percentController.setConponentsVisible(visible: false, animated: animated)
transitionRenderer.animate(fromDataToPie: true, animated: animated) { [weak self] in
self?.pieController.pieChartRenderer.drawPie = true
self?.percentController.mainPecentChartRenderer.isEnabled = true
}
} else {
if !pieController.chartsCollection.isBlank {
let fromHorizontalRange = pieController.currentHorizontalMainChartRange
let toHorizontalRange = percentController.initialHorizontalRange
pieController.pieChartRenderer.setup(horizontalRange: toHorizontalRange, animated: animated)
pieController.previewBarChartRenderer.setup(horizontalRange: toHorizontalRange, animated: animated)
pieController.pieChartRenderer.setVisible(false, animated: animated)
pieController.previewBarChartRenderer.setVisible(false, animated: animated)
percentController.updateChartsVisibility(visibility: pieController.chartVisibility, animated: false)
percentController.setupMainChart(horizontalRange: fromHorizontalRange, animated: false)
percentController.previewPercentChartRenderer.setup(horizontalRange: fromHorizontalRange, animated: false)
percentController.setConponentsVisible(visible: false, animated: false)
}
percentController.willAppear(animated: animated)
pieController.willDisappear(animated: animated)
if animated {
pieController.pieChartRenderer.drawPie = false
percentController.mainPecentChartRenderer.isEnabled = false
setupTransitionRenderer()
transitionRenderer.animate(fromDataToPie: false, animated: true) {
self.pieController.pieChartRenderer.drawPie = true
self.percentController.mainPecentChartRenderer.isEnabled = true
}
}
}
self.setBackButtonVisibilityClosure?(isZoomed, animated)
}
func setupTransitionRenderer() {
transitionRenderer.setup(verticalRange: percentController.currentVerticalMainChartRange, animated: false)
transitionRenderer.setup(horizontalRange: percentController.currentHorizontalMainChartRange, animated: false)
transitionRenderer.visiblePieComponents = pieController.visiblePieDataWithCurrentPreviewRange
transitionRenderer.visiblePercentageData = percentController.currentlyVisiblePercentageData
}
override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
if isZoomed {
pieController.updateChartsVisibility(visibility: visibility, animated: animated)
} else {
percentController.updateChartsVisibility(visibility: visibility, animated: animated)
}
}
var visibleChartValues: [ChartsCollection.Chart] {
let visibility = isZoomed ? pieController.chartVisibility : percentController.chartVisibility
let collection = isZoomed ? pieController.chartsCollection : percentController.chartsCollection
let visibleCharts: [ChartsCollection.Chart] = visibility.enumerated().compactMap { args in
args.element ? collection.chartValues[args.offset] : nil
}
return visibleCharts
}
override var actualChartVisibility: [Bool] {
return isZoomed ? pieController.chartVisibility : percentController.chartVisibility
}
override var actualChartsCollection: ChartsCollection {
return isZoomed ? pieController.chartsCollection : percentController.chartsCollection
}
override func chartInteractionDidBegin(point: CGPoint) {
if isZoomed {
pieController.chartInteractionDidBegin(point: point)
} else {
percentController.chartInteractionDidBegin(point: point)
}
}
override func chartInteractionDidEnd() {
if isZoomed {
pieController.chartInteractionDidEnd()
} else {
percentController.chartInteractionDidEnd()
}
}
override var drawChartVisibity: Bool {
return true
}
override var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
if isZoomed {
return pieController.currentChartHorizontalRangeFraction
} else {
return percentController.currentChartHorizontalRangeFraction
}
}
override func cancelChartInteraction() {
if isZoomed {
return pieController.hideDetailsView(animated: true)
} else {
return percentController.hideDetailsView(animated: true)
}
}
override func didTapZoomIn(date: Date) {
guard isZoomed == false else { return }
cancelChartInteraction()
let currentCollection = percentController.chartsCollection
let range: Int = Constants.zoomedRange
guard let (foundDate, index) = percentController.findClosestDateTo(dateToFind: date) else { return }
var lowIndex = max(0, index - range / 2)
var highIndex = min(currentCollection.axisValues.count - 1, index + range / 2)
if lowIndex == 0 {
highIndex = lowIndex + (range - 1)
} else if highIndex == currentCollection.axisValues.count - 1 {
lowIndex = highIndex - (range - 1)
}
let newValues = currentCollection.chartValues.map { chart in
return ChartsCollection.Chart(color: chart.color,
name: chart.name,
values: Array(chart.values[(lowIndex...highIndex)]))
}
let newCollection = ChartsCollection(axisValues: Array(currentCollection.axisValues[(lowIndex...highIndex)]),
chartValues: newValues)
let selectedRange = CGFloat(foundDate.timeIntervalSince1970 - .day)...CGFloat(foundDate.timeIntervalSince1970)
pieController.initialize(chartsCollection: newCollection, initialDate: date, totalHorizontalRange: 0...1, totalVerticalRange: 0...1)
pieController.initialHorizontalRange = selectedRange
switchToChart(chartsCollection: newCollection, isZoomed: true, animated: true)
}
override func didTapZoomOut() {
self.pieController.deselectSegment(completion: { [weak self] in
guard let self = self else { return }
self.switchToChart(chartsCollection: self.percentController.chartsCollection, isZoomed: false, animated: true)
})
}
override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>) {
if isZoomed {
return pieController.chartRangeFractionDidUpdated(rangeFraction)
} else {
return percentController.chartRangeFractionDidUpdated(rangeFraction)
}
}
override func apply(colorMode: ColorMode, animated: Bool) {
super.apply(colorMode: colorMode, animated: animated)
pieController.apply(colorMode: colorMode, animated: animated)
percentController.apply(colorMode: colorMode, animated: animated)
transitionRenderer.backgroundColor = colorMode.chartBackgroundColor
}
}

View file

@ -0,0 +1,198 @@
//
// PieChartComponentController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/14/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class PieChartComponentController: GeneralChartComponentController {
let pieChartRenderer: PieChartRenderer
let previewBarChartRenderer: BarChartRenderer
var barWidth: CGFloat = 1
var chartBars: BarChartRenderer.BarsData = .blank
init(isZoomed: Bool,
pieChartRenderer: PieChartRenderer,
previewBarChartRenderer: BarChartRenderer) {
self.pieChartRenderer = pieChartRenderer
self.previewBarChartRenderer = previewBarChartRenderer
super.init(isZoomed: isZoomed)
}
override func initialize(chartsCollection: ChartsCollection, initialDate: Date, totalHorizontalRange _: ClosedRange<CGFloat>, totalVerticalRange _: ClosedRange<CGFloat>) {
let (width, chartBars, totalHorizontalRange, _) = BarChartRenderer.BarsData.initialComponents(chartsCollection: chartsCollection)
self.barWidth = width
self.chartBars = chartBars
super.initialize(chartsCollection: chartsCollection,
initialDate: initialDate,
totalHorizontalRange: totalHorizontalRange,
totalVerticalRange: BaseConstants.defaultRange)
self.previewBarChartRenderer.bars = chartBars
self.previewBarChartRenderer.fillToTop = true
pieChartRenderer.valuesFormatter = PercentConstants.percentValueFormatter
pieChartRenderer.setup(horizontalRange: initialHorizontalRange, animated: false)
previewBarChartRenderer.setup(verticalRange: initialVerticalRange, animated: false)
previewBarChartRenderer.setup(horizontalRange: initialHorizontalRange, animated: false)
pieChartRenderer.updatePercentageData(pieDataWithCurrentPreviewRange, animated: false)
pieChartRenderer.selectSegmentAt(at: nil, animated: false)
}
private var pieDataWithCurrentPreviewRange: [PieChartRenderer.PieComponent] {
let range = currentHorizontalMainChartRange
var pieComponents = chartsCollection.chartValues.map { PieChartRenderer.PieComponent(color: $0.color,
value: 0) }
guard var valueIndex = chartsCollection.axisValues.firstIndex(where: { CGFloat($0.timeIntervalSince1970) > (range.lowerBound + 1)}) else {
return pieComponents
}
var count = 0
while valueIndex < chartsCollection.axisValues.count, CGFloat(chartsCollection.axisValues[valueIndex].timeIntervalSince1970) <= range.upperBound {
count += 1
for pieIndex in pieComponents.indices {
pieComponents[pieIndex].value += CGFloat(chartsCollection.chartValues[pieIndex].values[valueIndex])
}
valueIndex += 1
}
return pieComponents
}
var visiblePieDataWithCurrentPreviewRange: [PieChartRenderer.PieComponent] {
let currentData = pieDataWithCurrentPreviewRange
return chartVisibility.enumerated().compactMap { $0.element ? currentData[$0.offset] : nil }
}
override func willAppear(animated: Bool) {
pieChartRenderer.setup(horizontalRange: initialHorizontalRange, animated: animated)
pieChartRenderer.setVisible(true, animated: animated)
previewBarChartRenderer.setup(verticalRange: totalVerticalRange, animated: animated)
previewBarChartRenderer.setup(horizontalRange: totalHorizontalRange, animated: animated)
previewBarChartRenderer.setVisible(true, animated: animated)
updatePreviewRangeClosure?(currentChartHorizontalRangeFraction, animated)
pieChartRenderer.updatePercentageData(pieDataWithCurrentPreviewRange, animated: false)
super.willAppear(animated: animated)
}
override func setupChartRangePaging() {
let valuesCount = chartsCollection.axisValues.count
guard valuesCount > 0 else { return }
chartRangePagingClosure?(true, 1.0 / CGFloat(valuesCount))
}
override func chartRangeDidUpdated(_ updatedRange: ClosedRange<CGFloat>) {
if isChartInteractionBegun {
chartInteractionDidBegin(point: lastChartInteractionPoint)
}
initialHorizontalRange = updatedRange
setupMainChart(horizontalRange: updatedRange, animated: true)
updateSelectedDataLabelIfNeeded()
}
func setupMainChart(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
pieChartRenderer.setup(horizontalRange: horizontalRange, animated: animated)
pieChartRenderer.updatePercentageData(pieDataWithCurrentPreviewRange, animated: animated)
}
override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
super.updateChartsVisibility(visibility: visibility, animated: animated)
for (index, isVisible) in visibility.enumerated() {
pieChartRenderer.setComponentVisible(isVisible, at: index, animated: animated)
previewBarChartRenderer.setComponentVisible(isVisible, at: index, animated: animated)
}
if let segment = pieChartRenderer.selectedSegment {
if !visibility[segment] {
pieChartRenderer.selectSegmentAt(at: nil, animated: true)
}
}
updateSelectedDataLabelIfNeeded()
}
func deselectSegment(completion: @escaping () -> Void) {
if pieChartRenderer.hasSelectedSegments {
hideDetailsView(animated: true)
pieChartRenderer.selectSegmentAt(at: nil, animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + .defaultDuration / 2) {
completion()
}
} else {
completion()
}
}
func updateSelectedDataLabelIfNeeded() {
if let segment = pieChartRenderer.selectedSegment {
self.setDetailsChartVisibleClosure?(true, true)
self.setDetailsViewModel?(chartDetailsViewModel(segmentInde: segment), false)
self.setDetailsViewPositionClosure?(chartFrame().width / 4)
} else {
self.setDetailsChartVisibleClosure?(false, true)
}
}
func chartDetailsViewModel(segmentInde: Int) -> ChartDetailsViewModel {
let pieItem = pieDataWithCurrentPreviewRange[segmentInde]
let title = chartsCollection.chartValues[segmentInde].name
let valueString = BaseConstants.detailsNumberFormatter.string(from: pieItem.value)
let viewModel = ChartDetailsViewModel(title: "",
showArrow: false,
showPrefixes: false,
values: [ChartDetailsViewModel.Value(prefix: nil,
title: title,
value: valueString,
color: pieItem.color,
visible: true)],
totalValue: nil,
tapAction: nil)
return viewModel
}
override var currentMainRangeRenderer: BaseChartRenderer {
return pieChartRenderer
}
override var currentPreviewRangeRenderer: BaseChartRenderer {
return previewBarChartRenderer
}
var lastInteractionPoint: CGPoint = .zero
override func chartInteractionDidBegin(point: CGPoint) {
lastInteractionPoint = point
}
override func chartInteractionDidEnd() {
if let segment = pieChartRenderer.selectedItemIndex(at: lastInteractionPoint) {
if pieChartRenderer.selectedSegment == segment {
pieChartRenderer.selectSegmentAt(at: nil, animated: true)
} else {
pieChartRenderer.selectSegmentAt(at: segment, animated: true)
}
updateSelectedDataLabelIfNeeded()
}
}
override func hideDetailsView(animated: Bool) {
pieChartRenderer.selectSegmentAt(at: nil, animated: animated)
updateSelectedDataLabelIfNeeded()
}
override func updateChartRangeTitle(animated: Bool) {
let fromDate = Date(timeIntervalSince1970: TimeInterval(currentHorizontalMainChartRange.lowerBound) + .day + 1)
let toDate = Date(timeIntervalSince1970: TimeInterval(currentHorizontalMainChartRange.upperBound))
if Calendar.utc.startOfDay(for: fromDate) == Calendar.utc.startOfDay(for: toDate) {
let stirng = BaseConstants.headerFullZoomedFormatter.string(from: fromDate)
self.setChartTitleClosure?(stirng, animated)
} else {
let stirng = "\(BaseConstants.headerMediumRangeFormatter.string(from: fromDate)) - \(BaseConstants.headerMediumRangeFormatter.string(from: toDate))"
self.setChartTitleClosure?(stirng, animated)
}
}
}

View file

@ -0,0 +1,226 @@
//
// BarsComponentController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/14/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class BarsComponentController: GeneralChartComponentController {
let mainBarsRenderer: BarChartRenderer
let horizontalScalesRenderer: HorizontalScalesRenderer
let verticalScalesRenderer: VerticalScalesRenderer
let previewBarsChartRenderer: BarChartRenderer
private(set) var barsWidth: CGFloat = 1
private (set) var chartBars: BarChartRenderer.BarsData = .blank
init(isZoomed: Bool,
mainBarsRenderer: BarChartRenderer,
horizontalScalesRenderer: HorizontalScalesRenderer,
verticalScalesRenderer: VerticalScalesRenderer,
previewBarsChartRenderer: BarChartRenderer) {
self.mainBarsRenderer = mainBarsRenderer
self.horizontalScalesRenderer = horizontalScalesRenderer
self.verticalScalesRenderer = verticalScalesRenderer
self.previewBarsChartRenderer = previewBarsChartRenderer
self.mainBarsRenderer.optimizationLevel = BaseConstants.barsChartOptimizationLevel
self.previewBarsChartRenderer.optimizationLevel = BaseConstants.barsChartOptimizationLevel
super.init(isZoomed: isZoomed)
}
override func initialize(chartsCollection: ChartsCollection, initialDate: Date, totalHorizontalRange _: ClosedRange<CGFloat>, totalVerticalRange _: ClosedRange<CGFloat>) {
let (width, chartBars, totalHorizontalRange, totalVerticalRange) = BarChartRenderer.BarsData.initialComponents(chartsCollection: chartsCollection)
self.chartBars = chartBars
self.barsWidth = width
super.initialize(chartsCollection: chartsCollection,
initialDate: initialDate,
totalHorizontalRange: totalHorizontalRange,
totalVerticalRange: totalVerticalRange)
}
override func setupInitialChartRange(initialDate: Date) {
guard let first = chartsCollection.axisValues.first?.timeIntervalSince1970,
let last = chartsCollection.axisValues.last?.timeIntervalSince1970 else { return }
let rangeStart = CGFloat(first)
let rangeEnd = CGFloat(last)
if isZoomed {
let initalDate = CGFloat(initialDate.timeIntervalSince1970)
initialHorizontalRange = max(initalDate - barsWidth, rangeStart)...min(initalDate + GeneralChartComponentConstants.defaultZoomedRangeLength - barsWidth, rangeEnd)
initialVerticalRange = totalVerticalRange
} else {
super.setupInitialChartRange(initialDate: initialDate)
}
}
override func willAppear(animated: Bool) {
mainBarsRenderer.bars = self.chartBars
previewBarsChartRenderer.bars = self.chartBars
previewBarsChartRenderer.setup(verticalRange: totalVerticalRange, animated: animated)
previewBarsChartRenderer.setup(horizontalRange: totalHorizontalRange, animated: animated)
setupMainChart(verticalRange: initialVerticalRange, animated: animated)
setupMainChart(horizontalRange: initialHorizontalRange, animated: animated)
updateChartVerticalRanges(horizontalRange: initialHorizontalRange, animated: animated)
super.willAppear(animated: animated)
updatePreviewRangeClosure?(currentChartHorizontalRangeFraction, animated)
setConponentsVisible(visible: true, animated: animated)
updateHorizontalLimitLabels(animated: animated, forceUpdate: true)
}
override func chartRangeDidUpdated(_ updatedRange: ClosedRange<CGFloat>) {
super.chartRangeDidUpdated(updatedRange)
if !isZoomed {
initialHorizontalRange = updatedRange
}
setupMainChart(horizontalRange: updatedRange, animated: false)
updateHorizontalLimitLabels(animated: true, forceUpdate: false)
updateChartVerticalRanges(horizontalRange: updatedRange, animated: true)
}
func updateHorizontalLimitLabels(animated: Bool, forceUpdate: Bool) {
updateHorizontalLimitLabels(horizontalScalesRenderer: horizontalScalesRenderer,
horizontalRange: currentHorizontalMainChartRange,
scaleType: isZoomed ? .hour : .day,
forceUpdate: forceUpdate,
animated: animated)
}
func prepareAppearanceAnimation(horizontalRnage: ClosedRange<CGFloat>) {
setupMainChart(horizontalRange: horizontalRnage, animated: false)
setConponentsVisible(visible: false, animated: false)
}
func setConponentsVisible(visible: Bool, animated: Bool) {
mainBarsRenderer.setVisible(visible, animated: animated)
horizontalScalesRenderer.setVisible(visible, animated: animated)
verticalScalesRenderer.setVisible(visible, animated: animated)
previewBarsChartRenderer.setVisible(visible, animated: animated)
}
func setupMainChart(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
mainBarsRenderer.setup(horizontalRange: horizontalRange, animated: animated)
horizontalScalesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
verticalScalesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
}
var visibleBars: BarChartRenderer.BarsData {
let visibleComponents: [BarChartRenderer.BarsData.Component] = chartVisibility.enumerated().compactMap { args in
args.element ? chartBars.components[args.offset] : nil
}
return BarChartRenderer.BarsData(barWidth: chartBars.barWidth,
locations: chartBars.locations,
components: visibleComponents)
}
func updateChartVerticalRanges(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
if let range = BarChartRenderer.BarsData.verticalRange(bars: visibleBars,
calculatingRange: horizontalRange,
addBounds: true) {
let (range, labels) = verticalLimitsLabels(verticalRange: range)
if verticalScalesRenderer.verticalRange.end != range {
verticalScalesRenderer.setup(verticalLimitsLabels: labels, animated: animated)
}
verticalScalesRenderer.setVisible(true, animated: animated)
setupMainChart(verticalRange: range, animated: animated)
} else {
verticalScalesRenderer.setVisible(false, animated: animated)
}
if let range = BarChartRenderer.BarsData.verticalRange(bars: visibleBars) {
previewBarsChartRenderer.setup(verticalRange: range, animated: animated)
}
}
func setupMainChart(verticalRange: ClosedRange<CGFloat>, animated: Bool) {
mainBarsRenderer.setup(verticalRange: verticalRange, animated: animated)
horizontalScalesRenderer.setup(verticalRange: verticalRange, animated: animated)
verticalScalesRenderer.setup(verticalRange: verticalRange, animated: animated)
}
override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
super.updateChartsVisibility(visibility: visibility, animated: animated)
for (index, isVisible) in visibility.enumerated() {
mainBarsRenderer.setComponentVisible(isVisible, at: index, animated: animated)
previewBarsChartRenderer.setComponentVisible(isVisible, at: index, animated: animated)
}
updateChartVerticalRanges(horizontalRange: currentHorizontalMainChartRange, animated: true)
}
var visibleChartValues: [ChartsCollection.Chart] {
let visibleCharts: [ChartsCollection.Chart] = chartVisibility.enumerated().compactMap { args in
args.element ? chartsCollection.chartValues[args.offset] : nil
}
return visibleCharts
}
override func chartDetailsViewModel(closestDate: Date, pointIndex: Int) -> ChartDetailsViewModel {
var viewModel = super.chartDetailsViewModel(closestDate: closestDate, pointIndex: pointIndex)
let visibleChartValues = self.visibleChartValues
let totalSumm: CGFloat = visibleChartValues.map { CGFloat($0.values[pointIndex]) }.reduce(0, +)
viewModel.totalValue = ChartDetailsViewModel.Value(prefix: nil,
title: "Total",
value: BaseConstants.detailsNumberFormatter.string(from: totalSumm),
color: .white,
visible: visibleChartValues.count > 1)
return viewModel
}
override var currentMainRangeRenderer: BaseChartRenderer {
return mainBarsRenderer
}
override var currentPreviewRangeRenderer: BaseChartRenderer {
return previewBarsChartRenderer
}
override func showDetailsView(at chartPosition: CGFloat, detailsViewPosition: CGFloat, dataIndex: Int, date: Date, animted: Bool) {
let rangeWithOffset = detailsViewPosition - barsWidth / currentHorizontalMainChartRange.distance * chartFrame().width / 2
super.showDetailsView(at: chartPosition, detailsViewPosition: rangeWithOffset, dataIndex: dataIndex, date: date, animted: animted)
mainBarsRenderer.setSelectedIndex(dataIndex, animated: true)
}
override func hideDetailsView(animated: Bool) {
super.hideDetailsView(animated: animated)
mainBarsRenderer.setSelectedIndex(nil, animated: animated)
}
override func apply(colorMode: ColorMode, animated: Bool) {
super.apply(colorMode: colorMode, animated: animated)
horizontalScalesRenderer.labelsColor = colorMode.chartLabelsColor
verticalScalesRenderer.labelsColor = colorMode.chartLabelsColor
verticalScalesRenderer.axisXColor = colorMode.barChartStrongLinesColor
verticalScalesRenderer.horizontalLinesColor = colorMode.barChartStrongLinesColor
mainBarsRenderer.update(backgroundColor: colorMode.chartBackgroundColor, animated: false)
previewBarsChartRenderer.update(backgroundColor: colorMode.chartBackgroundColor, animated: false)
}
override func updateChartRangeTitle(animated: Bool) {
let fromDate = Date(timeIntervalSince1970: TimeInterval(currentHorizontalMainChartRange.lowerBound + barsWidth))
let toDate = Date(timeIntervalSince1970: TimeInterval(currentHorizontalMainChartRange.upperBound))
if Calendar.utc.startOfDay(for: fromDate) == Calendar.utc.startOfDay(for: toDate) {
let stirng = BaseConstants.headerFullZoomedFormatter.string(from: fromDate)
self.setChartTitleClosure?(stirng, animated)
} else {
let stirng = "\(BaseConstants.headerMediumRangeFormatter.string(from: fromDate)) - \(BaseConstants.headerMediumRangeFormatter.string(from: toDate))"
self.setChartTitleClosure?(stirng, animated)
}
}
}

View file

@ -0,0 +1,249 @@
//
// DailyBarsChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class DailyBarsChartController: BaseChartController {
let barsController: BarsComponentController
let linesController: LinesComponentController
override init(chartsCollection: ChartsCollection) {
let horizontalScalesRenderer = HorizontalScalesRenderer()
let verticalScalesRenderer = VerticalScalesRenderer()
barsController = BarsComponentController(isZoomed: false,
mainBarsRenderer: BarChartRenderer(),
horizontalScalesRenderer: horizontalScalesRenderer,
verticalScalesRenderer: verticalScalesRenderer,
previewBarsChartRenderer: BarChartRenderer())
linesController = LinesComponentController(isZoomed: true,
userLinesTransitionAnimation: false,
mainLinesRenderer: LinesChartRenderer(),
horizontalScalesRenderer: horizontalScalesRenderer,
verticalScalesRenderer: verticalScalesRenderer,
verticalLineRenderer: VerticalLinesRenderer(),
lineBulletsRenerer: LineBulletsRenerer(),
previewLinesChartRenderer: LinesChartRenderer())
super.init(chartsCollection: chartsCollection)
[barsController, linesController].forEach { controller in
controller.chartFrame = { [unowned self] in self.chartFrame() }
controller.cartViewBounds = { [unowned self] in self.cartViewBounds() }
controller.zoomInOnDateClosure = { [unowned self] date in
self.didTapZoomIn(date: date)
}
controller.setChartTitleClosure = { [unowned self] (title, animated) in
self.setChartTitleClosure?(title, animated)
}
controller.setDetailsViewPositionClosure = { [unowned self] (position) in
self.setDetailsViewPositionClosure?(position)
}
controller.setDetailsChartVisibleClosure = { [unowned self] (visible, animated) in
self.setDetailsChartVisibleClosure?(visible, animated)
}
controller.setDetailsViewModel = { [unowned self] (viewModel, animated) in
self.setDetailsViewModel?(viewModel, animated)
}
controller.updatePreviewRangeClosure = { [unowned self] (fraction, animated) in
self.chartRangeUpdatedClosure?(fraction, animated)
}
controller.chartRangePagingClosure = { [unowned self] (isEnabled, pageSize) in
self.setChartRangePagingEnabled(isEnabled: isEnabled, minimumSelectionSize: pageSize)
}
}
}
override var mainChartRenderers: [ChartViewRenderer] {
return [barsController.mainBarsRenderer,
linesController.mainLinesRenderer,
barsController.horizontalScalesRenderer,
barsController.verticalScalesRenderer,
linesController.verticalLineRenderer,
linesController.lineBulletsRenerer,
// performanceRenderer
]
}
override var navigationRenderers: [ChartViewRenderer] {
return [barsController.previewBarsChartRenderer,
linesController.previewLinesChartRenderer]
}
override func initializeChart() {
barsController.initialize(chartsCollection: initialChartsCollection,
initialDate: Date(),
totalHorizontalRange: BaseConstants.defaultRange,
totalVerticalRange: BaseConstants.defaultRange)
switchToChart(chartsCollection: barsController.chartsCollection, isZoomed: false, animated: false)
}
func switchToChart(chartsCollection: ChartsCollection, isZoomed: Bool, animated: Bool) {
if animated {
TimeInterval.setDefaultSuration(.expandAnimationDuration)
DispatchQueue.main.asyncAfter(deadline: .now() + .expandAnimationDuration) {
TimeInterval.setDefaultSuration(.osXDuration)
}
}
super.isZoomed = isZoomed
if isZoomed {
let toHorizontalRange = linesController.initialHorizontalRange
let destinationHorizontalRange = (toHorizontalRange.lowerBound - barsController.barsWidth)...(toHorizontalRange.upperBound - barsController.barsWidth)
let initialChartVerticalRange = lineProportionAnimationRange()
linesController.mainLinesRenderer.setup(horizontalRange: barsController.currentHorizontalMainChartRange, animated: false)
linesController.previewLinesChartRenderer.setup(horizontalRange: barsController.currentPreviewHorizontalRange, animated: false)
linesController.mainLinesRenderer.setup(verticalRange: initialChartVerticalRange, animated: false)
linesController.previewLinesChartRenderer.setup(verticalRange: initialChartVerticalRange, animated: false)
linesController.mainLinesRenderer.setVisible(false, animated: false)
linesController.previewLinesChartRenderer.setVisible(false, animated: false)
barsController.setupMainChart(horizontalRange: destinationHorizontalRange, animated: animated)
barsController.previewBarsChartRenderer.setup(horizontalRange: linesController.totalHorizontalRange, animated: animated)
barsController.mainBarsRenderer.setVisible(false, animated: animated)
barsController.previewBarsChartRenderer.setVisible(false, animated: animated)
linesController.willAppear(animated: animated)
barsController.willDisappear(animated: animated)
linesController.updateChartsVisibility(visibility: linesController.chartLines.map { _ in true }, animated: false)
} else {
if !linesController.chartsCollection.isBlank {
barsController.hideDetailsView(animated: false)
let visibleVerticalRange = BarChartRenderer.BarsData.verticalRange(bars: barsController.visibleBars,
calculatingRange: barsController.initialHorizontalRange) ?? BaseConstants.defaultRange
barsController.mainBarsRenderer.setup(verticalRange: visibleVerticalRange, animated: false)
let toHorizontalRange = barsController.initialHorizontalRange
let destinationChartVerticalRange = lineProportionAnimationRange()
linesController.setupMainChart(horizontalRange: toHorizontalRange, animated: animated)
linesController.mainLinesRenderer.setup(verticalRange: destinationChartVerticalRange, animated: animated)
linesController.previewLinesChartRenderer.setup(verticalRange: destinationChartVerticalRange, animated: animated)
linesController.previewLinesChartRenderer.setup(horizontalRange: barsController.totalHorizontalRange, animated: animated)
linesController.mainLinesRenderer.setVisible(false, animated: animated)
linesController.previewLinesChartRenderer.setVisible(false, animated: animated)
}
barsController.willAppear(animated: animated)
linesController.willDisappear(animated: animated)
}
self.setBackButtonVisibilityClosure?(isZoomed, animated)
self.refreshChartToolsClosure?(animated)
}
override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
if isZoomed {
linesController.updateChartsVisibility(visibility: visibility, animated: animated)
} else {
barsController.updateChartsVisibility(visibility: visibility, animated: animated)
}
}
var visibleChartValues: [ChartsCollection.Chart] {
let visibility = isZoomed ? linesController.chartVisibility : barsController.chartVisibility
let collection = isZoomed ? linesController.chartsCollection : barsController.chartsCollection
let visibleCharts: [ChartsCollection.Chart] = visibility.enumerated().compactMap { args in
args.element ? collection.chartValues[args.offset] : nil
}
return visibleCharts
}
override var actualChartVisibility: [Bool] {
return isZoomed ? linesController.chartVisibility : barsController.chartVisibility
}
override var actualChartsCollection: ChartsCollection {
return isZoomed ? linesController.chartsCollection : barsController.chartsCollection
}
override func chartInteractionDidBegin(point: CGPoint) {
if isZoomed {
linesController.chartInteractionDidBegin(point: point)
} else {
barsController.chartInteractionDidBegin(point: point)
}
}
override func chartInteractionDidEnd() {
if isZoomed {
linesController.chartInteractionDidEnd()
} else {
barsController.chartInteractionDidEnd()
}
}
override var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
if isZoomed {
return linesController.currentChartHorizontalRangeFraction
} else {
return barsController.currentChartHorizontalRangeFraction
}
}
override func cancelChartInteraction() {
if isZoomed {
return linesController.hideDetailsView(animated: true)
} else {
return barsController.hideDetailsView(animated: true)
}
}
override func didTapZoomIn(date: Date) {
guard isZoomed == false else { return }
if isZoomed {
return linesController.hideDetailsView(animated: true)
}
self.getDetailsData?(date, { updatedCollection in
if let updatedCollection = updatedCollection {
self.linesController.initialize(chartsCollection: updatedCollection,
initialDate: date,
totalHorizontalRange: 0...1,
totalVerticalRange: 0...1)
self.switchToChart(chartsCollection: updatedCollection, isZoomed: true, animated: true)
}
})
}
func lineProportionAnimationRange() -> ClosedRange<CGFloat> {
let visibleLines = self.barsController.chartVisibility.enumerated().compactMap { $0.element ? self.linesController.chartLines[$0.offset] : nil }
let linesRange = LinesChartRenderer.LineData.verticalRange(lines: visibleLines) ?? BaseConstants.defaultRange
let barsRange = BarChartRenderer.BarsData.verticalRange(bars: self.barsController.visibleBars,
calculatingRange: self.linesController.totalHorizontalRange) ?? BaseConstants.defaultRange
let range = 0...(linesRange.upperBound / barsRange.distance * self.barsController.currentVerticalMainChartRange.distance)
return range
}
override func didTapZoomOut() {
cancelChartInteraction()
switchToChart(chartsCollection: barsController.chartsCollection, isZoomed: false, animated: true)
}
override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>) {
if isZoomed {
return linesController.chartRangeFractionDidUpdated(rangeFraction)
} else {
return barsController.chartRangeFractionDidUpdated(rangeFraction)
}
}
override func apply(colorMode: ColorMode, animated: Bool) {
super.apply(colorMode: colorMode, animated: animated)
linesController.apply(colorMode: colorMode, animated: animated)
barsController.apply(colorMode: colorMode, animated: animated)
}
override var drawChartVisibity: Bool {
return isZoomed
}
}
//TODO: Убрать Performance полоски сверзу чартов (Не забыть)
//TODO: Добавить ховеры на кнопки

View file

@ -0,0 +1,210 @@
//
// LinesComponentController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/14/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class LinesComponentController: GeneralChartComponentController {
let mainLinesRenderer: LinesChartRenderer
let horizontalScalesRenderer: HorizontalScalesRenderer
let verticalScalesRenderer: VerticalScalesRenderer
let verticalLineRenderer: VerticalLinesRenderer
let lineBulletsRenerer: LineBulletsRenerer
let previewLinesChartRenderer: LinesChartRenderer
private let zoomedLinesRenderer = LinesChartRenderer()
private let zoomedPreviewLinesRenderer = LinesChartRenderer()
private let userLinesTransitionAnimation: Bool
private(set) var chartLines: [LinesChartRenderer.LineData] = []
init(isZoomed: Bool,
userLinesTransitionAnimation: Bool,
mainLinesRenderer: LinesChartRenderer,
horizontalScalesRenderer: HorizontalScalesRenderer,
verticalScalesRenderer: VerticalScalesRenderer,
verticalLineRenderer: VerticalLinesRenderer,
lineBulletsRenerer: LineBulletsRenerer,
previewLinesChartRenderer: LinesChartRenderer) {
self.mainLinesRenderer = mainLinesRenderer
self.horizontalScalesRenderer = horizontalScalesRenderer
self.verticalScalesRenderer = verticalScalesRenderer
self.verticalLineRenderer = verticalLineRenderer
self.lineBulletsRenerer = lineBulletsRenerer
self.previewLinesChartRenderer = previewLinesChartRenderer
self.userLinesTransitionAnimation = userLinesTransitionAnimation
super.init(isZoomed: isZoomed)
self.mainLinesRenderer.lineWidth = BaseConstants.mainChartLineWidth
self.mainLinesRenderer.optimizationLevel = BaseConstants.linesChartOptimizationLevel
self.previewLinesChartRenderer.lineWidth = BaseConstants.previewChartLineWidth
self.previewLinesChartRenderer.optimizationLevel = BaseConstants.previewLinesChartOptimizationLevel
self.lineBulletsRenerer.isEnabled = false
}
override func initialize(chartsCollection: ChartsCollection,
initialDate: Date,
totalHorizontalRange _: ClosedRange<CGFloat>,
totalVerticalRange _: ClosedRange<CGFloat>) {
let (chartLines, totalHorizontalRange, totalVerticalRange) = LinesChartRenderer.LineData.initialComponents(chartsCollection: chartsCollection)
self.chartLines = chartLines
self.lineBulletsRenerer.bullets = self.chartLines.map { LineBulletsRenerer.Bullet(coordinate: $0.points.first ?? .zero,
color: $0.color)}
super.initialize(chartsCollection: chartsCollection,
initialDate: initialDate,
totalHorizontalRange: totalHorizontalRange,
totalVerticalRange: totalVerticalRange)
self.mainLinesRenderer.setup(verticalRange: totalVerticalRange, animated: true)
}
override func willAppear(animated: Bool) {
mainLinesRenderer.setLines(lines: self.chartLines, animated: animated && userLinesTransitionAnimation)
previewLinesChartRenderer.setLines(lines: self.chartLines, animated: animated && userLinesTransitionAnimation)
previewLinesChartRenderer.setup(verticalRange: totalVerticalRange, animated: animated)
previewLinesChartRenderer.setup(horizontalRange: totalHorizontalRange, animated: animated)
setupMainChart(verticalRange: initialVerticalRange, animated: animated)
setupMainChart(horizontalRange: initialHorizontalRange, animated: animated)
updateChartVerticalRanges(horizontalRange: initialHorizontalRange, animated: animated)
super.willAppear(animated: animated)
updatePreviewRangeClosure?(currentChartHorizontalRangeFraction, animated)
setConponentsVisible(visible: true, animated: animated)
updateHorizontalLimitLabels(animated: animated, forceUpdate: true)
}
override func chartRangeDidUpdated(_ updatedRange: ClosedRange<CGFloat>) {
super.chartRangeDidUpdated(updatedRange)
if !isZoomed {
initialHorizontalRange = updatedRange
}
setupMainChart(horizontalRange: updatedRange, animated: false)
updateHorizontalLimitLabels(animated: true, forceUpdate: false)
updateChartVerticalRanges(horizontalRange: updatedRange, animated: true)
}
func updateHorizontalLimitLabels(animated: Bool, forceUpdate: Bool) {
updateHorizontalLimitLabels(horizontalScalesRenderer: horizontalScalesRenderer,
horizontalRange: currentHorizontalMainChartRange,
scaleType: isZoomed ? .hour : .day,
forceUpdate: forceUpdate,
animated: animated)
}
func prepareAppearanceAnimation(horizontalRnage: ClosedRange<CGFloat>) {
setupMainChart(horizontalRange: horizontalRnage, animated: false)
setConponentsVisible(visible: false, animated: false)
}
func setConponentsVisible(visible: Bool, animated: Bool) {
mainLinesRenderer.setVisible(visible, animated: animated)
horizontalScalesRenderer.setVisible(visible, animated: animated)
verticalScalesRenderer.setVisible(visible, animated: animated)
verticalLineRenderer.setVisible(visible, animated: animated)
previewLinesChartRenderer.setVisible(visible, animated: animated)
lineBulletsRenerer.setVisible(visible, animated: animated)
}
func setupMainChart(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
mainLinesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
horizontalScalesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
verticalScalesRenderer.setup(horizontalRange: horizontalRange, animated: animated)
verticalLineRenderer.setup(horizontalRange: horizontalRange, animated: animated)
lineBulletsRenerer.setup(horizontalRange: horizontalRange, animated: animated)
}
var visibleLines: [LinesChartRenderer.LineData] {
return chartVisibility.enumerated().compactMap { $0.element ? chartLines[$0.offset] : nil }
}
func updateChartVerticalRanges(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
if let range = LinesChartRenderer.LineData.verticalRange(lines: visibleLines,
calculatingRange: horizontalRange,
addBounds: true) {
let (range, labels) = verticalLimitsLabels(verticalRange: range)
if verticalScalesRenderer.verticalRange.end != range {
verticalScalesRenderer.setup(verticalLimitsLabels: labels, animated: animated)
}
setupMainChart(verticalRange: range, animated: animated)
verticalScalesRenderer.setVisible(true, animated: animated)
} else {
verticalScalesRenderer.setVisible(false, animated: animated)
}
if let range = LinesChartRenderer.LineData.verticalRange(lines: visibleLines) {
previewLinesChartRenderer.setup(verticalRange: range, animated: animated)
}
}
func setupMainChart(verticalRange: ClosedRange<CGFloat>, animated: Bool) {
mainLinesRenderer.setup(verticalRange: verticalRange, animated: animated)
horizontalScalesRenderer.setup(verticalRange: verticalRange, animated: animated)
verticalScalesRenderer.setup(verticalRange: verticalRange, animated: animated)
verticalLineRenderer.setup(verticalRange: verticalRange, animated: animated)
lineBulletsRenerer.setup(verticalRange: verticalRange, animated: animated)
}
override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
super.updateChartsVisibility(visibility: visibility, animated: animated)
for (index, isVisible) in visibility.enumerated() {
mainLinesRenderer.setLineVisible(isVisible, at: index, animated: animated)
previewLinesChartRenderer.setLineVisible(isVisible, at: index, animated: animated)
lineBulletsRenerer.setLineVisible(isVisible, at: index, animated: animated)
}
updateChartVerticalRanges(horizontalRange: currentHorizontalMainChartRange, animated: true)
}
override var currentMainRangeRenderer: BaseChartRenderer {
return mainLinesRenderer
}
override var currentPreviewRangeRenderer: BaseChartRenderer {
return previewLinesChartRenderer
}
override func showDetailsView(at chartPosition: CGFloat, detailsViewPosition: CGFloat, dataIndex: Int, date: Date, animted: Bool) {
super.showDetailsView(at: chartPosition, detailsViewPosition: detailsViewPosition, dataIndex: dataIndex, date: date, animted: animted)
verticalLineRenderer.values = [chartPosition]
verticalLineRenderer.isEnabled = true
lineBulletsRenerer.isEnabled = true
lineBulletsRenerer.setVisible(true, animated: animted)
lineBulletsRenerer.bullets = chartLines.compactMap { chart in
return LineBulletsRenerer.Bullet(coordinate: chart.points[dataIndex], color: chart.color)
}
}
override func hideDetailsView(animated: Bool) {
super.hideDetailsView(animated: animated)
verticalLineRenderer.values = []
verticalLineRenderer.isEnabled = false
lineBulletsRenerer.isEnabled = false
}
override func apply(colorMode: ColorMode, animated: Bool) {
super.apply(colorMode: colorMode, animated: animated)
horizontalScalesRenderer.labelsColor = colorMode.chartLabelsColor
verticalScalesRenderer.labelsColor = colorMode.chartLabelsColor
verticalScalesRenderer.axisXColor = colorMode.chartStrongLinesColor
verticalScalesRenderer.horizontalLinesColor = colorMode.chartHelperLinesColor
lineBulletsRenerer.setInnerColor(colorMode.chartBackgroundColor, animated: animated)
verticalLineRenderer.linesColor = colorMode.chartStrongLinesColor
}
}

View file

@ -0,0 +1,243 @@
//
// StackedBarsChartController.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class StackedBarsChartController: BaseChartController {
let barsController: BarsComponentController
let zoomedBarsController: BarsComponentController
override init(chartsCollection: ChartsCollection) {
let horizontalScalesRenderer = HorizontalScalesRenderer()
let verticalScalesRenderer = VerticalScalesRenderer()
barsController = BarsComponentController(isZoomed: false,
mainBarsRenderer: BarChartRenderer(),
horizontalScalesRenderer: horizontalScalesRenderer,
verticalScalesRenderer: verticalScalesRenderer,
previewBarsChartRenderer: BarChartRenderer())
zoomedBarsController = BarsComponentController(isZoomed: true,
mainBarsRenderer: BarChartRenderer(),
horizontalScalesRenderer: horizontalScalesRenderer,
verticalScalesRenderer: verticalScalesRenderer,
previewBarsChartRenderer: BarChartRenderer())
super.init(chartsCollection: chartsCollection)
[barsController, zoomedBarsController].forEach { controller in
controller.chartFrame = { [unowned self] in self.chartFrame() }
controller.cartViewBounds = { [unowned self] in self.cartViewBounds() }
controller.zoomInOnDateClosure = { [unowned self] date in
self.didTapZoomIn(date: date)
}
controller.setChartTitleClosure = { [unowned self] (title, animated) in
self.setChartTitleClosure?(title, animated)
}
controller.setDetailsViewPositionClosure = { [unowned self] (position) in
self.setDetailsViewPositionClosure?(position)
}
controller.setDetailsChartVisibleClosure = { [unowned self] (visible, animated) in
self.setDetailsChartVisibleClosure?(visible, animated)
}
controller.setDetailsViewModel = { [unowned self] (viewModel, animated) in
self.setDetailsViewModel?(viewModel, animated)
}
controller.updatePreviewRangeClosure = { [unowned self] (fraction, animated) in
self.chartRangeUpdatedClosure?(fraction, animated)
}
controller.chartRangePagingClosure = { [unowned self] (isEnabled, pageSize) in
self.setChartRangePagingEnabled(isEnabled: isEnabled, minimumSelectionSize: pageSize)
}
}
}
override var mainChartRenderers: [ChartViewRenderer] {
return [barsController.mainBarsRenderer,
zoomedBarsController.mainBarsRenderer,
barsController.horizontalScalesRenderer,
barsController.verticalScalesRenderer,
// performanceRenderer
]
}
override var navigationRenderers: [ChartViewRenderer] {
return [barsController.previewBarsChartRenderer,
zoomedBarsController.previewBarsChartRenderer]
}
override func initializeChart() {
barsController.initialize(chartsCollection: initialChartsCollection,
initialDate: Date(),
totalHorizontalRange: BaseConstants.defaultRange,
totalVerticalRange: BaseConstants.defaultRange)
switchToChart(chartsCollection: barsController.chartsCollection, isZoomed: false, animated: false)
}
func switchToChart(chartsCollection: ChartsCollection, isZoomed: Bool, animated: Bool) {
if animated {
TimeInterval.setDefaultSuration(.expandAnimationDuration)
DispatchQueue.main.asyncAfter(deadline: .now() + .expandAnimationDuration) {
TimeInterval.setDefaultSuration(.osXDuration)
}
}
super.isZoomed = isZoomed
if isZoomed {
let toHorizontalRange = zoomedBarsController.initialHorizontalRange
let destinationHorizontalRange = (toHorizontalRange.lowerBound - barsController.barsWidth)...(toHorizontalRange.upperBound - barsController.barsWidth)
let verticalVisibleRange = barsController.currentVerticalMainChartRange
let initialVerticalRange = verticalVisibleRange.lowerBound...(verticalVisibleRange.upperBound + verticalVisibleRange.distance * 10)
zoomedBarsController.mainBarsRenderer.setup(horizontalRange: barsController.currentHorizontalMainChartRange, animated: false)
zoomedBarsController.previewBarsChartRenderer.setup(horizontalRange: barsController.currentPreviewHorizontalRange, animated: false)
zoomedBarsController.mainBarsRenderer.setup(verticalRange: initialVerticalRange, animated: false)
zoomedBarsController.previewBarsChartRenderer.setup(verticalRange: initialVerticalRange, animated: false)
zoomedBarsController.mainBarsRenderer.setVisible(true, animated: false)
zoomedBarsController.previewBarsChartRenderer.setVisible(true, animated: false)
barsController.setupMainChart(horizontalRange: destinationHorizontalRange, animated: animated)
barsController.previewBarsChartRenderer.setup(horizontalRange: zoomedBarsController.totalHorizontalRange, animated: animated)
barsController.mainBarsRenderer.setVisible(false, animated: animated)
barsController.previewBarsChartRenderer.setVisible(false, animated: animated)
zoomedBarsController.willAppear(animated: animated)
barsController.willDisappear(animated: animated)
zoomedBarsController.updateChartsVisibility(visibility: barsController.chartVisibility, animated: false)
zoomedBarsController.mainBarsRenderer.setup(verticalRange: zoomedBarsController.currentVerticalMainChartRange, animated: animated, timeFunction: .easeOut)
zoomedBarsController.previewBarsChartRenderer.setup(verticalRange: zoomedBarsController.currentPreviewVerticalRange, animated: animated, timeFunction: .easeOut)
} else {
if !zoomedBarsController.chartsCollection.isBlank {
barsController.hideDetailsView(animated: false)
barsController.chartVisibility = zoomedBarsController.chartVisibility
let visibleVerticalRange = BarChartRenderer.BarsData.verticalRange(bars: barsController.visibleBars,
calculatingRange: barsController.initialHorizontalRange) ?? BaseConstants.defaultRange
barsController.mainBarsRenderer.setup(verticalRange: visibleVerticalRange, animated: false)
let toHorizontalRange = barsController.initialHorizontalRange
let verticalVisibleRange = barsController.initialVerticalRange
let targetVerticalRange = verticalVisibleRange.lowerBound...(verticalVisibleRange.upperBound + verticalVisibleRange.distance * 10)
zoomedBarsController.setupMainChart(horizontalRange: toHorizontalRange, animated: animated)
zoomedBarsController.mainBarsRenderer.setup(verticalRange: targetVerticalRange, animated: animated, timeFunction: .easeIn)
zoomedBarsController.previewBarsChartRenderer.setup(verticalRange: targetVerticalRange, animated: animated, timeFunction: .easeIn)
zoomedBarsController.previewBarsChartRenderer.setup(horizontalRange: barsController.totalHorizontalRange, animated: animated)
DispatchQueue.main.asyncAfter(deadline: .now() + .defaultDuration) { [weak self] in
self?.zoomedBarsController.mainBarsRenderer.setVisible(false, animated: false)
self?.zoomedBarsController.previewBarsChartRenderer.setVisible(false, animated: false)
}
}
barsController.willAppear(animated: animated)
zoomedBarsController.willDisappear(animated: animated)
if !zoomedBarsController.chartsCollection.isBlank {
barsController.updateChartsVisibility(visibility: zoomedBarsController.chartVisibility, animated: false)
}
}
self.setBackButtonVisibilityClosure?(isZoomed, animated)
}
override func updateChartsVisibility(visibility: [Bool], animated: Bool) {
if isZoomed {
zoomedBarsController.updateChartsVisibility(visibility: visibility, animated: animated)
} else {
barsController.updateChartsVisibility(visibility: visibility, animated: animated)
}
}
var visibleChartValues: [ChartsCollection.Chart] {
let visibility = isZoomed ? zoomedBarsController.chartVisibility : barsController.chartVisibility
let collection = isZoomed ? zoomedBarsController.chartsCollection : barsController.chartsCollection
let visibleCharts: [ChartsCollection.Chart] = visibility.enumerated().compactMap { args in
args.element ? collection.chartValues[args.offset] : nil
}
return visibleCharts
}
override var actualChartVisibility: [Bool] {
return isZoomed ? zoomedBarsController.chartVisibility : barsController.chartVisibility
}
override var actualChartsCollection: ChartsCollection {
return isZoomed ? zoomedBarsController.chartsCollection : barsController.chartsCollection
}
override func chartInteractionDidBegin(point: CGPoint) {
if isZoomed {
zoomedBarsController.chartInteractionDidBegin(point: point)
} else {
barsController.chartInteractionDidBegin(point: point)
}
}
override func chartInteractionDidEnd() {
if isZoomed {
zoomedBarsController.chartInteractionDidEnd()
} else {
barsController.chartInteractionDidEnd()
}
}
override var drawChartVisibity: Bool {
return true
}
override var currentChartHorizontalRangeFraction: ClosedRange<CGFloat> {
if isZoomed {
return zoomedBarsController.currentChartHorizontalRangeFraction
} else {
return barsController.currentChartHorizontalRangeFraction
}
}
override func cancelChartInteraction() {
if isZoomed {
return zoomedBarsController.hideDetailsView(animated: true)
} else {
return barsController.hideDetailsView(animated: true)
}
}
override func didTapZoomIn(date: Date) {
guard isZoomed == false else { return }
if isZoomed {
return zoomedBarsController.hideDetailsView(animated: true)
}
self.getDetailsData?(date, { updatedCollection in
if let updatedCollection = updatedCollection {
self.zoomedBarsController.initialize(chartsCollection: updatedCollection,
initialDate: date,
totalHorizontalRange: 0...1,
totalVerticalRange: 0...1)
self.switchToChart(chartsCollection: updatedCollection, isZoomed: true, animated: true)
}
})
}
override func didTapZoomOut() {
cancelChartInteraction()
switchToChart(chartsCollection: barsController.chartsCollection, isZoomed: false, animated: true)
}
override func updateChartRange(_ rangeFraction: ClosedRange<CGFloat>) {
if isZoomed {
return zoomedBarsController.chartRangeFractionDidUpdated(rangeFraction)
} else {
return barsController.chartRangeFractionDidUpdated(rangeFraction)
}
}
override func apply(colorMode: ColorMode, animated: Bool) {
super.apply(colorMode: colorMode, animated: animated)
zoomedBarsController.apply(colorMode: colorMode, animated: animated)
barsController.apply(colorMode: colorMode, animated: animated)
}
}

View file

@ -0,0 +1,293 @@
//
// BarChartRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class BarChartRenderer: BaseChartRenderer {
struct BarsData {
static let blank = BarsData(barWidth: 1, locations: [], components: [])
var barWidth: CGFloat
var locations: [CGFloat]
var components: [Component]
struct Component {
var color: UIColor
var values: [CGFloat]
}
}
var fillToTop: Bool = false
private(set) lazy var selectedIndexAnimator: AnimationController<CGFloat> = {
return AnimationController(current: 0, refreshClosure: self.refreshClosure)
}()
func setSelectedIndex(_ index: Int?, animated: Bool) {
let destinationValue: CGFloat = (index == nil) ? 0 : 1
if animated {
if index != nil {
selectedBarIndex = index
}
self.selectedIndexAnimator.completionClosure = {
self.selectedBarIndex = index
}
guard self.selectedIndexAnimator.end != destinationValue else { return }
self.selectedIndexAnimator.animate(to: destinationValue, duration: .defaultDuration)
} else {
self.selectedIndexAnimator.set(current: destinationValue)
self.selectedBarIndex = index
}
}
private var selectedBarIndex: Int? {
didSet {
setNeedsDisplay()
}
}
var generalUnselectedAlpha: CGFloat = 0.5
private var componentsAnimators: [AnimationController<CGFloat>] = []
var bars: BarsData = BarsData(barWidth: 1, locations: [], components: []) {
willSet {
if bars.components.count != newValue.components.count {
componentsAnimators = newValue.components.map { _ in AnimationController<CGFloat>(current: 1, refreshClosure: self.refreshClosure) }
}
}
didSet {
setNeedsDisplay()
}
}
func setComponentVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
componentsAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
private lazy var backgroundColorAnimator = AnimationController(current: UIColorContainer(color: .white), refreshClosure: refreshClosure)
func update(backgroundColor: UIColor, animated: Bool) {
if animated {
backgroundColorAnimator.animate(to: UIColorContainer(color: backgroundColor), duration: .defaultDuration)
} else {
backgroundColorAnimator.set(current: UIColorContainer(color: backgroundColor))
}
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let chartsAlpha = chartAlphaAnimator.current
if chartsAlpha == 0 { return }
let range = renderRange(bounds: bounds, chartFrame: chartFrame)
var selectedPaths: [[CGRect]] = bars.components.map { _ in [] }
var unselectedPaths: [[CGRect]] = bars.components.map { _ in [] }
if var barIndex = bars.locations.firstIndex(where: { $0 >= range.lowerBound }) {
if fillToTop {
barIndex = max(0, barIndex - 1)
while barIndex < bars.locations.count {
let currentLocation = bars.locations[barIndex]
let right = transform(toChartCoordinateHorizontal: currentLocation, chartFrame: chartFrame).roundedUpToPixelGrid()
let left = transform(toChartCoordinateHorizontal: currentLocation - bars.barWidth, chartFrame: chartFrame).roundedUpToPixelGrid()
var summ: CGFloat = 0
for (index, component) in bars.components.enumerated() {
summ += componentsAnimators[index].current * component.values[barIndex]
}
guard summ > 0 else {
barIndex += 1
continue
}
var stackedValue: CGFloat = 0
for (index, component) in bars.components.enumerated() {
let visibilityPercent = componentsAnimators[index].current
if visibilityPercent == 0 { continue }
let bottomFraction = stackedValue
let topFraction = stackedValue + ((component.values[barIndex] * visibilityPercent) / summ)
let rect = CGRect(x: left,
y: chartFrame.maxY - chartFrame.height * topFraction,
width: right - left,
height: chartFrame.height * (topFraction - bottomFraction))
if selectedBarIndex == barIndex {
selectedPaths[index].append(rect)
} else {
unselectedPaths[index].append(rect)
}
stackedValue = topFraction
}
if currentLocation > range.upperBound {
break
}
barIndex += 1
}
for (index, component) in bars.components.enumerated() {
context.saveGState()
context.setFillColor(component.color.withAlphaComponent(chartsAlpha * component.color.alphaValue).cgColor)
context.fill(selectedPaths[index])
let resultAlpha: CGFloat = 1.0 - (1.0 - generalUnselectedAlpha) * selectedIndexAnimator.current
context.setFillColor(component.color.withAlphaComponent(chartsAlpha * component.color.alphaValue * resultAlpha).cgColor)
context.fill(unselectedPaths[index])
context.restoreGState()
}
} else {
var selectedPaths: [[CGRect]] = bars.components.map { _ in [] }
barIndex = max(0, barIndex - 1)
var currentLocation = bars.locations[barIndex]
var leftX = transform(toChartCoordinateHorizontal: currentLocation - bars.barWidth, chartFrame: chartFrame)
var rightX: CGFloat = 0
let startPoint = CGPoint(x: leftX,
y: transform(toChartCoordinateVertical: verticalRange.current.lowerBound, chartFrame: chartFrame))
var backgourndPaths: [[CGPoint]] = bars.components.map { _ in Array() }
let itemsCount = ((bars.locations.count - barIndex) * 2) + 4
for path in backgourndPaths.indices {
backgourndPaths[path].reserveCapacity(itemsCount)
backgourndPaths[path].append(startPoint)
}
var maxValues: [CGFloat] = bars.components.map { _ in 0 }
while barIndex < bars.locations.count {
currentLocation = bars.locations[barIndex]
rightX = transform(toChartCoordinateHorizontal: currentLocation, chartFrame: chartFrame)
var stackedValue: CGFloat = 0
var bottomY: CGFloat = transform(toChartCoordinateVertical: stackedValue, chartFrame: chartFrame)
for (index, component) in bars.components.enumerated() {
let visibilityPercent = componentsAnimators[index].current
if visibilityPercent == 0 { continue }
let height = component.values[barIndex] * visibilityPercent
stackedValue += height
let topY = transform(toChartCoordinateVertical: stackedValue, chartFrame: chartFrame)
let componentHeight = (bottomY - topY)
maxValues[index] = max(maxValues[index], componentHeight)
if selectedBarIndex == barIndex {
let rect = CGRect(x: leftX,
y: topY,
width: rightX - leftX,
height: componentHeight)
selectedPaths[index].append(rect)
}
backgourndPaths[index].append(CGPoint(x: leftX, y: topY))
backgourndPaths[index].append(CGPoint(x: rightX, y: topY))
bottomY = topY
}
if currentLocation > range.upperBound {
break
}
leftX = rightX
barIndex += 1
}
let endPoint = CGPoint(x: transform(toChartCoordinateHorizontal: currentLocation, chartFrame: chartFrame).roundedUpToPixelGrid(),
y: transform(toChartCoordinateVertical: verticalRange.current.lowerBound, chartFrame: chartFrame))
let colorOffset = Double((1.0 - (1.0 - generalUnselectedAlpha) * selectedIndexAnimator.current) * chartsAlpha)
for (index, component) in bars.components.enumerated().reversed() {
if maxValues[index] < optimizationLevel {
continue
}
context.saveGState()
backgourndPaths[index].append(endPoint)
context.setFillColor(UIColor.valueBetween(start: backgroundColorAnimator.current.color,
end: component.color,
offset: colorOffset).cgColor)
context.beginPath()
context.addLines(between: backgourndPaths[index])
context.closePath()
context.fillPath()
context.restoreGState()
}
for (index, component) in bars.components.enumerated().reversed() {
context.setFillColor(component.color.withAlphaComponent(chartsAlpha * component.color.alphaValue).cgColor)
context.fill(selectedPaths[index])
}
}
}
}
}
extension BarChartRenderer.BarsData {
static func initialComponents(chartsCollection: ChartsCollection) ->
(width: CGFloat,
chartBars: BarChartRenderer.BarsData,
totalHorizontalRange: ClosedRange<CGFloat>,
totalVerticalRange: ClosedRange<CGFloat>) {
let width: CGFloat
if chartsCollection.axisValues.count > 1 {
width = CGFloat(abs(chartsCollection.axisValues[1].timeIntervalSince1970 - chartsCollection.axisValues[0].timeIntervalSince1970))
} else {
width = 1
}
let components = chartsCollection.chartValues.map { BarChartRenderer.BarsData.Component(color: $0.color,
values: $0.values.map { CGFloat($0) }) }
let chartBars = BarChartRenderer.BarsData(barWidth: width,
locations: chartsCollection.axisValues.map { CGFloat($0.timeIntervalSince1970) },
components: components)
let totalVerticalRange = BarChartRenderer.BarsData.verticalRange(bars: chartBars) ?? 0...1
let totalHorizontalRange = BarChartRenderer.BarsData.visibleHorizontalRange(bars: chartBars, width: width) ?? 0...1
return (width: width, chartBars: chartBars, totalHorizontalRange: totalHorizontalRange, totalVerticalRange: totalVerticalRange)
}
static func visibleHorizontalRange(bars: BarChartRenderer.BarsData, width: CGFloat) -> ClosedRange<CGFloat>? {
guard let firstPoint = bars.locations.first,
let lastPoint = bars.locations.last,
firstPoint <= lastPoint else {
return nil
}
return (firstPoint - width)...lastPoint
}
static func verticalRange(bars: BarChartRenderer.BarsData, calculatingRange: ClosedRange<CGFloat>? = nil, addBounds: Bool = false) -> ClosedRange<CGFloat>? {
guard bars.components.count > 0 else {
return nil
}
if let calculatingRange = calculatingRange {
guard var index = bars.locations.firstIndex(where: { $0 >= calculatingRange.lowerBound && $0 <= calculatingRange.upperBound }) else {
return nil
}
var vMax: CGFloat = bars.components[0].values[index]
while index < bars.locations.count {
var summ: CGFloat = 0
for component in bars.components {
summ += component.values[index]
}
vMax = max(vMax, summ)
if bars.locations[index] > calculatingRange.upperBound {
break
}
index += 1
}
return 0...vMax
} else {
var index = 0
var vMax: CGFloat = bars.components[0].values[index]
while index < bars.locations.count {
var summ: CGFloat = 0
for component in bars.components {
summ += component.values[index]
}
vMax = max(vMax, summ)
index += 1
}
return 0...vMax
}
}
}

View file

@ -0,0 +1,116 @@
//
// BaseChartRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
private let exponentialAnimationTrashold: CGFloat = 100
class BaseChartRenderer: ChartViewRenderer {
var containerViews: [UIView] = []
var optimizationLevel: CGFloat = 1 {
didSet {
setNeedsDisplay()
}
}
var isEnabled: Bool = true {
didSet {
setNeedsDisplay()
}
}
private(set) lazy var chartAlphaAnimator: AnimationController<CGFloat> = {
return AnimationController(current: 1, refreshClosure: self.refreshClosure)
}()
func setVisible(_ visible: Bool, animated: Bool) {
let destinationValue: CGFloat = visible ? 1 : 0
guard self.chartAlphaAnimator.end != destinationValue else { return }
if animated {
self.chartAlphaAnimator.animate(to: destinationValue, duration: .defaultDuration)
} else {
self.chartAlphaAnimator.set(current: destinationValue)
}
}
lazy var horizontalRange = AnimationController<ClosedRange<CGFloat>>(current: 0...1, refreshClosure: refreshClosure)
lazy var verticalRange = AnimationController<ClosedRange<CGFloat>>(current: 0...1, refreshClosure: refreshClosure)
func setup(verticalRange: ClosedRange<CGFloat>, animated: Bool, timeFunction: TimeFunction? = nil) {
guard self.verticalRange.end != verticalRange else {
self.verticalRange.timeFunction = timeFunction ?? .linear
return
}
if animated {
let function: TimeFunction
if let timeFunction = timeFunction {
function = timeFunction
} else if self.verticalRange.current.distance > 0 && verticalRange.distance > 0 {
if self.verticalRange.current.distance / verticalRange.distance > exponentialAnimationTrashold {
function = .easeIn
} else if verticalRange.distance / self.verticalRange.current.distance > exponentialAnimationTrashold {
function = .easeOut
} else {
function = .linear
}
} else {
function = .linear
}
self.verticalRange.animate(to: verticalRange, duration: .defaultDuration, timeFunction: function)
} else {
self.verticalRange.set(current: verticalRange)
}
}
func setup(horizontalRange: ClosedRange<CGFloat>, animated: Bool) {
guard self.horizontalRange.end != horizontalRange else { return }
if animated {
let animationCurve: TimeFunction = self.horizontalRange.current.distance > horizontalRange.distance ? .easeOut : .easeIn
self.horizontalRange.animate(to: horizontalRange, duration: .defaultDuration, timeFunction: animationCurve)
} else {
self.horizontalRange.set(current: horizontalRange)
}
}
func transform(toChartCoordinateHorizontal x: CGFloat, chartFrame: CGRect) -> CGFloat {
return chartFrame.origin.x + (x - horizontalRange.current.lowerBound) / horizontalRange.current.distance * chartFrame.width
}
func transform(toChartCoordinateVertical y: CGFloat, chartFrame: CGRect) -> CGFloat {
return chartFrame.height + chartFrame.origin.y - (y - verticalRange.current.lowerBound) / verticalRange.current.distance * chartFrame.height
}
func transform(toChartCoordinate point: CGPoint, chartFrame: CGRect) -> CGPoint {
return CGPoint(x: transform(toChartCoordinateHorizontal: point.x, chartFrame: chartFrame),
y: transform(toChartCoordinateVertical: point.y, chartFrame: chartFrame))
}
func renderRange(bounds: CGRect, chartFrame: CGRect) -> ClosedRange<CGFloat> {
let lowerBound = horizontalRange.current.lowerBound - chartFrame.origin.x / chartFrame.width * horizontalRange.current.distance
let upperBound = horizontalRange.current.upperBound + (bounds.width - chartFrame.width - chartFrame.origin.x) / chartFrame.width * horizontalRange.current.distance
guard lowerBound <= upperBound else {
print("Error: Unexpecated bounds range!")
return 0...1
}
return lowerBound...upperBound
}
func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
fatalError("abstract")
}
func setNeedsDisplay() {
containerViews.forEach { $0.setNeedsDisplay() }
}
var refreshClosure: () -> Void {
return { [weak self] in
self?.setNeedsDisplay()
}
}
}

View file

@ -0,0 +1,147 @@
//
// ChartDetailsRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/13/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class ChartDetailsRenderer: BaseChartRenderer, ColorModeContainer {
private lazy var colorAnimator = AnimationController<CGFloat>(current: 1, refreshClosure: refreshClosure)
private var fromColorMode: ColorMode = .day
private var currentColorMode: ColorMode = .day
func apply(colorMode: ColorMode, animated: Bool) {
if currentColorMode != colorMode {
fromColorMode = currentColorMode
currentColorMode = colorMode
if animated {
colorAnimator.set(current: 0)
colorAnimator.animate(to: 1, duration: .defaultDuration)
} else {
colorAnimator.set(current: 1)
}
}
}
private var valuesAnimators: [AnimationController<CGFloat>] = []
func setValueVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
valuesAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
var detailsViewModel: ChartDetailsViewModel = .blank {
didSet {
if detailsViewModel.values.count != valuesAnimators.count {
valuesAnimators = detailsViewModel.values.map { _ in AnimationController<CGFloat>(current: 1, refreshClosure: refreshClosure) }
}
setNeedsDisplay()
}
}
var detailsViewPosition: CGFloat = 0 {
didSet {
setNeedsDisplay()
}
}
var detailViewPositionOffset: CGFloat = 10
var detailViewTopOffset: CGFloat = 10
private var iconWidth: CGFloat = 10
private var margins: CGFloat = 10
private let cornerRadius: CGFloat = 5
private var rowHeight: CGFloat = 20
private let titleFont = UIFont.systemFont(ofSize: 14, weight: .bold)
private let prefixFont = UIFont.systemFont(ofSize: 14, weight: .bold)
private let labelsFont = UIFont.systemFont(ofSize: 14, weight: .medium)
private let valuesFont = UIFont.systemFont(ofSize: 14, weight: .bold)
private let labelsColor: UIColor = .black
private(set) var previousRenderBannerFrame: CGRect = .zero
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
previousRenderBannerFrame = .zero
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let generalAlpha = chartAlphaAnimator.current
if generalAlpha == 0 { return }
let widths: [(prefix: CGFloat, label: CGFloat, value: CGFloat)] = detailsViewModel.values.map { value in
var prefixWidth: CGFloat = 0
if let prefixText = value.prefix {
prefixWidth = (prefixText as NSString).boundingRect(with: bounds.size,
options: .usesLineFragmentOrigin,
attributes: [.font: prefixFont],
context: nil).width.rounded(.up) + margins
}
let labelWidth = (value.title as NSString).boundingRect(with: bounds.size,
options: .usesLineFragmentOrigin,
attributes: [.font: labelsFont],
context: nil).width.rounded(.up) + margins
let valueWidth = (value.value as NSString).boundingRect(with: bounds.size,
options: .usesLineFragmentOrigin,
attributes: [.font: valuesFont],
context: nil).width.rounded(.up)
return (prefixWidth, labelWidth, valueWidth)
}
let titleWidth = (detailsViewModel.title as NSString).boundingRect(with: bounds.size,
options: .usesLineFragmentOrigin,
attributes: [.font: titleFont],
context: nil).width
let prefixesWidth = widths.map { $0.prefix }.max() ?? 0
let labelsWidth = widths.map { $0.label }.max() ?? 0
let valuesWidth = widths.map { $0.value }.max() ?? 0
let totalWidth: CGFloat = max(prefixesWidth + labelsWidth + valuesWidth, titleWidth + iconWidth) + margins * 2
let totalHeight: CGFloat = CGFloat(detailsViewModel.values.count + 1) * rowHeight + margins * 2
let backgroundColor = UIColor.valueBetween(start: fromColorMode.chartDetailsViewColor,
end: currentColorMode.chartDetailsViewColor,
offset: Double(colorAnimator.current))
let titleAndTextColor = UIColor.valueBetween(start: fromColorMode.chartDetailsTextColor,
end: currentColorMode.chartDetailsTextColor,
offset: Double(colorAnimator.current))
let detailsViewFrame: CGRect
if totalWidth + detailViewTopOffset > detailsViewPosition {
detailsViewFrame = CGRect(x: detailsViewPosition + detailViewTopOffset,
y: detailViewTopOffset + chartFrame.minY,
width: totalWidth,
height: totalHeight)
} else {
detailsViewFrame = CGRect(x: detailsViewPosition - totalWidth - detailViewTopOffset,
y: detailViewTopOffset + chartFrame.minY,
width: totalWidth,
height: totalHeight)
}
previousRenderBannerFrame = detailsViewFrame
context.saveGState()
context.setFillColor(backgroundColor.cgColor)
context.beginPath()
context.addPath(CGPath(roundedRect: detailsViewFrame, cornerWidth: 5, cornerHeight: 5, transform: nil))
context.fillPath()
context.endPage()
context.restoreGState()
var drawY = detailsViewFrame.minY + margins + (rowHeight - titleFont.pointSize) / 2
(detailsViewModel.title as NSString).draw(at: CGPoint(x: detailsViewFrame.minX + margins, y: drawY), withAttributes: [.font: titleFont,
.foregroundColor: titleAndTextColor])
drawY += rowHeight
for (index, row) in widths.enumerated() {
let value = detailsViewModel.values[index]
if let prefixText = value.prefix {
(prefixText as NSString).draw(at: CGPoint(x: detailsViewFrame.minX + prefixesWidth - row.prefix,
y: drawY),
withAttributes: [.font: prefixText, .foregroundColor: titleAndTextColor])
}
(value.title as NSString).draw(at: CGPoint(x: detailsViewFrame.minX + prefixesWidth + margins,
y: drawY),
withAttributes: [.font: labelsFont, .foregroundColor: titleAndTextColor])
(value.value as NSString).draw(at: CGPoint(x: detailsViewFrame.minX + prefixesWidth + labelsWidth + valuesWidth - row.value + margins,
y: drawY),
withAttributes: [.font: labelsFont, .foregroundColor: value.color])
drawY += rowHeight
}
}
}

View file

@ -0,0 +1,99 @@
//
// HorizontalScalesRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/8/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class HorizontalScalesRenderer: BaseChartRenderer {
private var horizontalLabels: [LinesChartLabel] = []
private var animatedHorizontalLabels: [AnimatedLinesChartLabels] = []
var labelsVerticalOffset: CGFloat = 8
var labelsFont: UIFont = .systemFont(ofSize: 11)
var labelsColor: UIColor = .gray
func setup(labels: [LinesChartLabel], animated: Bool) {
if animated {
var labelsToKeepVisible: [LinesChartLabel] = []
let labelsToHide: [LinesChartLabel]
var labelsToShow: [LinesChartLabel] = []
for label in labels {
if horizontalLabels.contains(label) {
labelsToKeepVisible.append(label)
} else {
labelsToShow.append(label)
}
}
labelsToHide = horizontalLabels.filter { !labels.contains($0) }
animatedHorizontalLabels.removeAll()
horizontalLabels = labelsToKeepVisible
let showAnimation = AnimatedLinesChartLabels(labels: labelsToShow, alphaAnimator: AnimationController(current: 1.0, refreshClosure: refreshClosure))
showAnimation.isAppearing = true
showAnimation.alphaAnimator.set(current: 0)
showAnimation.alphaAnimator.animate(to: 1, duration: .defaultDuration)
showAnimation.alphaAnimator.completionClosure = { [weak self, weak showAnimation] in
guard let self = self, let showAnimation = showAnimation else { return }
self.animatedHorizontalLabels.removeAll(where: { $0 === showAnimation })
self.horizontalLabels = labels
}
let hideAnimation = AnimatedLinesChartLabels(labels: labelsToHide, alphaAnimator: AnimationController(current: 1.0, refreshClosure: refreshClosure))
hideAnimation.isAppearing = false
hideAnimation.alphaAnimator.set(current: 1)
hideAnimation.alphaAnimator.animate(to: 0, duration: .defaultDuration)
hideAnimation.alphaAnimator.completionClosure = { [weak self, weak hideAnimation] in
guard let self = self, let hideAnimation = hideAnimation else { return }
self.animatedHorizontalLabels.removeAll(where: { $0 === hideAnimation })
}
animatedHorizontalLabels.append(showAnimation)
animatedHorizontalLabels.append(hideAnimation)
} else {
horizontalLabels = labels
animatedHorizontalLabels = []
}
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let itemsAlpha = chartAlphaAnimator.current
guard itemsAlpha > 0 else { return }
let range = renderRange(bounds: bounds, chartFrame: chartFrame)
func drawHorizontalLabels(_ labels: [LinesChartLabel], color: UIColor) {
let attributes: [NSAttributedString.Key : Any] = [.foregroundColor: color,
.font: labelsFont]
let y = chartFrame.origin.y + chartFrame.height + labelsVerticalOffset
if let start = labels.firstIndex(where: { $0.value > range.lowerBound }) {
for index in start..<labels.count {
let label = labels[index]
let x = transform(toChartCoordinateHorizontal: label.value, chartFrame: chartFrame)
let rect = (label.text as NSString).boundingRect(with: bounds.size,
options: .usesLineFragmentOrigin,
attributes: attributes,
context: nil)
(label.text as NSString).draw(at: CGPoint(x: x - rect.width, y: y), withAttributes: attributes)
if label.value > range.upperBound {
break
}
}
}
}
let labelColorAlpha = labelsColor.alphaValue * itemsAlpha
drawHorizontalLabels(horizontalLabels, color: labelsColor.withAlphaComponent(labelColorAlpha * itemsAlpha))
for animation in animatedHorizontalLabels {
let color = labelsColor.withAlphaComponent(animation.alphaAnimator.current * labelColorAlpha)
drawHorizontalLabels(animation.labels, color: color)
}
}
}

View file

@ -0,0 +1,67 @@
//
// LineBulletsRenerer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/8/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class LineBulletsRenerer: BaseChartRenderer {
struct Bullet {
var coordinate: CGPoint
var color: UIColor
}
var bullets: [Bullet] = [] {
willSet {
if alphaAnimators.count != newValue.count {
alphaAnimators = newValue.map { _ in AnimationController<CGFloat>(current: 1.0, refreshClosure: refreshClosure) }
}
}
didSet {
setNeedsDisplay()
}
}
private var alphaAnimators: [AnimationController<CGFloat>] = []
private lazy var innerColorAnimator = AnimationController(current: UIColorContainer(color: .white), refreshClosure: refreshClosure)
public func setInnerColor(_ color: UIColor, animated: Bool) {
if animated {
innerColorAnimator.animate(to: UIColorContainer(color: color), duration: .defaultDuration)
} else {
innerColorAnimator.set(current: UIColorContainer(color: color))
}
}
var linesWidth: CGFloat = 2
var bulletRadius: CGFloat = 6
func setLineVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
alphaAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let generalAlpha = chartAlphaAnimator.current
if generalAlpha == 0 { return }
for (index, bullet) in bullets.enumerated() {
let alpha = alphaAnimators[index].current
if alpha == 0 { continue }
let centerX = transform(toChartCoordinateHorizontal: bullet.coordinate.x, chartFrame: chartFrame)
let centerY = transform(toChartCoordinateVertical: bullet.coordinate.y, chartFrame: chartFrame)
context.setFillColor(innerColorAnimator.current.color.withAlphaComponent(alpha).cgColor)
context.setStrokeColor(bullet.color.withAlphaComponent(alpha).cgColor)
context.setLineWidth(linesWidth)
let rect = CGRect(x: centerX - bulletRadius / 2,
y: centerY - bulletRadius / 2,
width: bulletRadius,
height: bulletRadius)
context.fillEllipse(in: rect)
context.strokeEllipse(in: rect)
}
}
}

View file

@ -0,0 +1,538 @@
//
// LinesChartRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class LinesChartRenderer: BaseChartRenderer {
struct LineData {
var color: UIColor
var points: [CGPoint]
}
private var linesAlphaAnimators: [AnimationController<CGFloat>] = []
var lineWidth: CGFloat = 1 {
didSet {
setNeedsDisplay()
}
}
private lazy var linesShapeAnimator = AnimationController<Double>(current: 1, refreshClosure: self.refreshClosure)
private var fromLines: [LineData] = []
private var toLines: [LineData] = []
func setLines(lines: [LineData], animated: Bool) {
if toLines.count != lines.count {
linesAlphaAnimators = lines.map { _ in AnimationController<CGFloat>(current: 1, refreshClosure: self.refreshClosure) }
}
if animated {
self.fromLines = self.toLines
self.toLines = lines
linesShapeAnimator.set(current: 1.0 - linesShapeAnimator.current)
linesShapeAnimator.completionClosure = {
self.fromLines = []
}
linesShapeAnimator.animate(to: 1, duration: .defaultDuration)
} else {
self.fromLines = []
self.toLines = lines
linesShapeAnimator.set(current: 1)
}
}
func setLineVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
linesAlphaAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let chartsAlpha = chartAlphaAnimator.current
if chartsAlpha == 0 { return }
let range = renderRange(bounds: bounds, chartFrame: chartFrame)
for (index, toLine) in toLines.enumerated() {
let alpha = linesAlphaAnimators[index].current * chartsAlpha
if alpha == 0 { continue }
context.setStrokeColor(toLine.color.withAlphaComponent(alpha).cgColor)
context.setLineWidth(lineWidth)
if linesShapeAnimator.isAnimating {
let animationOffset = linesShapeAnimator.current
let path = CGMutablePath()
let fromPoints = fromLines.safeElement(at: index)?.points ?? []
let toPoints = toLines.safeElement(at: index)?.points ?? []
var fromIndex: Int? = fromPoints.firstIndex(where: { $0.x >= range.lowerBound })
var toIndex: Int? = toPoints.firstIndex(where: { $0.x >= range.lowerBound })
let fromRange = verticalRange.start
let currentRange = verticalRange.current
let toRange = verticalRange.end
func convertFromPoint(_ fromPoint: CGPoint) -> CGPoint {
return CGPoint(x: fromPoint.x,
y: (fromPoint.y - fromRange.lowerBound) / fromRange.distance * currentRange.distance + currentRange.lowerBound)
}
func convertToPoint(_ toPoint: CGPoint) -> CGPoint {
return CGPoint(x: toPoint.x,
y: (toPoint.y - toRange.lowerBound) / toRange.distance * currentRange.distance + currentRange.lowerBound)
}
var previousFromPoint: CGPoint
var previousToPoint: CGPoint
let startFromPoint: CGPoint?
let startToPoint: CGPoint?
if let validFrom = fromIndex {
previousFromPoint = convertFromPoint(fromPoints[max(0, validFrom - 1)])
startFromPoint = previousFromPoint
} else {
previousFromPoint = .zero
startFromPoint = nil
}
if let validTo = toIndex {
previousToPoint = convertToPoint(toPoints[max(0, validTo - 1)])
startToPoint = previousToPoint
} else {
previousToPoint = .zero
startToPoint = nil
}
var combinedPoints: [CGPoint] = []
func add(pointToDraw: CGPoint) {
if let startFromPoint = startFromPoint,
pointToDraw.x < startFromPoint.x {
let animatedPoint = CGPoint(x: pointToDraw.x,
y: CGFloat.valueBetween(start: startFromPoint.y, end: pointToDraw.y, offset: animationOffset))
combinedPoints.append(transform(toChartCoordinate: animatedPoint, chartFrame: chartFrame))
} else if let startToPoint = startToPoint,
pointToDraw.x < startToPoint.x {
let animatedPoint = CGPoint(x: pointToDraw.x,
y: CGFloat.valueBetween(start: startToPoint.y, end: pointToDraw.y, offset: 1 - animationOffset))
combinedPoints.append(transform(toChartCoordinate: animatedPoint, chartFrame: chartFrame))
} else {
combinedPoints.append(transform(toChartCoordinate: pointToDraw, chartFrame: chartFrame))
}
}
if previousToPoint != .zero && previousFromPoint != .zero {
add(pointToDraw: (previousToPoint.x < previousFromPoint.x ? previousToPoint : previousFromPoint))
} else if previousToPoint != .zero {
add(pointToDraw: previousToPoint)
} else if previousFromPoint != .zero {
add(pointToDraw: previousFromPoint)
}
while let validFromIndex = fromIndex,
let validToIndex = toIndex,
validFromIndex < fromPoints.count,
validToIndex < toPoints.count {
let currentFromPoint = convertFromPoint(fromPoints[validFromIndex])
let currentToPoint = convertToPoint(toPoints[validToIndex])
let pointToAdd: CGPoint
if currentFromPoint.x == currentToPoint.x {
pointToAdd = CGPoint.valueBetween(start: currentFromPoint, end: currentToPoint, offset: animationOffset)
previousFromPoint = currentFromPoint
previousToPoint = currentToPoint
fromIndex = validFromIndex + 1
toIndex = validToIndex + 1
} else if currentFromPoint.x < currentToPoint.x {
if previousToPoint.x < currentFromPoint.x {
let offset = Double((currentFromPoint.x - previousToPoint.x) / (currentToPoint.x - previousToPoint.x))
let intermidiateToPoint = CGPoint.valueBetween(start: previousToPoint, end: currentToPoint, offset: offset)
pointToAdd = CGPoint.valueBetween(start: currentFromPoint, end: intermidiateToPoint, offset: animationOffset)
} else {
pointToAdd = currentFromPoint
}
previousFromPoint = currentFromPoint
fromIndex = validFromIndex + 1
} else {
if previousFromPoint.x < currentToPoint.x {
let offset = Double((currentToPoint.x - previousFromPoint.x) / (currentFromPoint.x - previousFromPoint.x))
let intermidiateFromPoint = CGPoint.valueBetween(start: previousFromPoint, end: currentFromPoint, offset: offset)
pointToAdd = CGPoint.valueBetween(start: intermidiateFromPoint, end: currentToPoint, offset: animationOffset)
} else {
pointToAdd = currentToPoint
}
previousToPoint = currentToPoint
toIndex = validToIndex + 1
}
add(pointToDraw: pointToAdd)
if (pointToAdd.x > range.upperBound) {
break
}
}
while let validToIndex = toIndex, validToIndex < toPoints.count {
var pointToAdd = convertToPoint(toPoints[validToIndex])
pointToAdd.y = CGFloat.valueBetween(start: previousFromPoint.y,
end: pointToAdd.y,
offset: animationOffset)
add(pointToDraw: pointToAdd)
if (pointToAdd.x > range.upperBound) {
break
}
toIndex = validToIndex + 1
}
while let validFromIndex = fromIndex, validFromIndex < fromPoints.count {
var pointToAdd = convertFromPoint(fromPoints[validFromIndex])
pointToAdd.y = CGFloat.valueBetween(start: previousToPoint.y,
end: pointToAdd.y,
offset: 1 - animationOffset)
add(pointToDraw: pointToAdd)
if (pointToAdd.x > range.upperBound) {
break
}
fromIndex = validFromIndex + 1
}
var index = 0
var lines: [CGPoint] = []
var currentChartPoint = combinedPoints[index]
lines.append(currentChartPoint)
var chartPoints = [currentChartPoint]
var minIndex = 0
var maxIndex = 0
index += 1
while index < combinedPoints.count {
currentChartPoint = combinedPoints[index]
if currentChartPoint.x - chartPoints[0].x < lineWidth * optimizationLevel {
chartPoints.append(currentChartPoint)
if currentChartPoint.y > chartPoints[maxIndex].y {
maxIndex = chartPoints.count - 1
}
if currentChartPoint.y < chartPoints[minIndex].y {
minIndex = chartPoints.count - 1
}
index += 1
} else {
if chartPoints.count == 1 {
lines.append(currentChartPoint)
lines.append(currentChartPoint)
chartPoints[0] = currentChartPoint
index += 1
minIndex = 0
maxIndex = 0
} else {
if minIndex < maxIndex {
if minIndex != 0 {
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
}
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
if maxIndex != chartPoints.count - 1 {
chartPoints = [chartPoints[maxIndex], chartPoints.last!]
} else {
chartPoints = [chartPoints[maxIndex]]
}
} else {
if maxIndex != 0 {
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
}
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
if minIndex != chartPoints.count - 1 {
chartPoints = [chartPoints[minIndex], chartPoints.last!]
} else {
chartPoints = [chartPoints[minIndex]]
}
}
if chartPoints.count == 2 {
if chartPoints[0].y < chartPoints[1].y {
minIndex = 0
maxIndex = 1
} else {
minIndex = 1
maxIndex = 0
}
} else {
minIndex = 0
maxIndex = 0
}
}
}
}
if chartPoints.count == 1 {
lines.append(currentChartPoint)
lines.append(currentChartPoint)
} else {
if minIndex < maxIndex {
if minIndex != 0 {
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
}
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
if maxIndex != chartPoints.count - 1 {
lines.append(chartPoints.last!)
lines.append(chartPoints.last!)
}
} else {
if maxIndex != 0 {
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
}
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
if minIndex != chartPoints.count - 1 {
lines.append(chartPoints.last!)
lines.append(chartPoints.last!)
}
}
}
if (lines.count % 2) == 1 {
lines.removeLast()
}
context.setLineCap(.round)
context.strokeLineSegments(between: lines)
} else {
let alpha = linesAlphaAnimators[index].current * chartsAlpha
if alpha == 0 { continue }
context.setStrokeColor(toLine.color.withAlphaComponent(alpha).cgColor)
context.setLineWidth(lineWidth)
if var index = toLine.points.firstIndex(where: { $0.x >= range.lowerBound }) {
var lines: [CGPoint] = []
index = max(0, index - 1)
var currentPoint = toLine.points[index]
var currentChartPoint = transform(toChartCoordinate: currentPoint, chartFrame: chartFrame)
lines.append(currentChartPoint)
//context.move(to: currentChartPoint)
var chartPoints = [currentChartPoint]
var minIndex = 0
var maxIndex = 0
index += 1
while index < toLine.points.count {
currentPoint = toLine.points[index]
currentChartPoint = transform(toChartCoordinate: currentPoint, chartFrame: chartFrame)
if currentChartPoint.x - chartPoints[0].x < lineWidth * optimizationLevel {
chartPoints.append(currentChartPoint)
if currentChartPoint.y > chartPoints[maxIndex].y {
maxIndex = chartPoints.count - 1
}
if currentChartPoint.y < chartPoints[minIndex].y {
minIndex = chartPoints.count - 1
}
index += 1
} else {
if chartPoints.count == 1 {
lines.append(currentChartPoint)
lines.append(currentChartPoint)
chartPoints[0] = currentChartPoint
index += 1
minIndex = 0
maxIndex = 0
} else {
if minIndex < maxIndex {
if minIndex != 0 {
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
}
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
if maxIndex != chartPoints.count - 1 {
chartPoints = [chartPoints[maxIndex], chartPoints.last!]
} else {
chartPoints = [chartPoints[maxIndex]]
}
} else {
if maxIndex != 0 {
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
}
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
if minIndex != chartPoints.count - 1 {
chartPoints = [chartPoints[minIndex], chartPoints.last!]
} else {
chartPoints = [chartPoints[minIndex]]
}
}
if chartPoints.count == 2 {
if chartPoints[0].y < chartPoints[1].y {
minIndex = 0
maxIndex = 1
} else {
minIndex = 1
maxIndex = 0
}
} else {
minIndex = 0
maxIndex = 0
}
}
}
if currentPoint.x > range.upperBound {
break
}
}
if chartPoints.count == 1 {
lines.append(currentChartPoint)
lines.append(currentChartPoint)
} else {
if minIndex < maxIndex {
if minIndex != 0 {
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
}
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
if maxIndex != chartPoints.count - 1 {
lines.append(chartPoints.last!)
lines.append(chartPoints.last!)
}
} else {
if maxIndex != 0 {
lines.append(chartPoints[maxIndex])
lines.append(chartPoints[maxIndex])
}
lines.append(chartPoints[minIndex])
lines.append(chartPoints[minIndex])
if minIndex != chartPoints.count - 1 {
lines.append(chartPoints.last!)
lines.append(chartPoints.last!)
}
}
}
if (lines.count % 2) == 1 {
lines.removeLast()
}
context.setLineCap(.round)
context.strokeLineSegments(between: lines)
}
// if var start = toLine.points.firstIndex(where: { $0.x > range.lowerBound }) {
// let alpha = linesAlphaAnimators[index].current * chartsAlpha
// if alpha == 0 { continue }
// context.setStrokeColor(toLine.color.withAlphaComponent(alpha).cgColor)
// context.setLineWidth(lineWidth)
//
// context.setLineCap(.round)
// start = max(0, start - 1)
// let startPoint = toLine.points[start]
// var lines: [CGPoint] = []
// var pointToDraw = CGPoint(x: transform(toChartCoordinateHorizontal: startPoint.x, chartFrame: chartFrame),
// y: transform(toChartCoordinateVertical: startPoint.y, chartFrame: chartFrame))
// for index in (start + 1)..<toLine.points.count {
// lines.append(pointToDraw)
// let point = toLine.points[index]
// pointToDraw = CGPoint(x: transform(toChartCoordinateHorizontal: point.x, chartFrame: chartFrame),
// y: transform(toChartCoordinateVertical: point.y, chartFrame: chartFrame))
// lines.append(pointToDraw)
// if point.x > range.upperBound {
// break
// }
// }
//
// context.strokeLineSegments(between: lines)
// }
}
}
}
}
extension LinesChartRenderer.LineData {
static func initialComponents(chartsCollection: ChartsCollection) -> (linesData: [LinesChartRenderer.LineData],
totalHorizontalRange: ClosedRange<CGFloat>,
totalVerticalRange: ClosedRange<CGFloat>) {
let lines: [LinesChartRenderer.LineData] = chartsCollection.chartValues.map { chart in
let points = chart.values.enumerated().map({ (arg) -> CGPoint in
return CGPoint(x: chartsCollection.axisValues[arg.offset].timeIntervalSince1970,
y: arg.element)
})
return LinesChartRenderer.LineData(color: chart.color, points: points)
}
let horizontalRange = LinesChartRenderer.LineData.horizontalRange(lines: lines) ?? BaseConstants.defaultRange
let verticalRange = LinesChartRenderer.LineData.verticalRange(lines: lines) ?? BaseConstants.defaultRange
return (linesData: lines, totalHorizontalRange: horizontalRange, totalVerticalRange: verticalRange)
}
static func horizontalRange(lines: [LinesChartRenderer.LineData]) -> ClosedRange<CGFloat>? {
guard let firstPoint = lines.first?.points.first else { return nil }
var hMin: CGFloat = firstPoint.x
var hMax: CGFloat = firstPoint.x
for line in lines {
if let first = line.points.first,
let last = line.points.last {
hMin = min(hMin, first.x)
hMax = max(hMax, last.x)
}
}
return hMin...hMax
}
static func verticalRange(lines: [LinesChartRenderer.LineData], calculatingRange: ClosedRange<CGFloat>? = nil, addBounds: Bool = false) -> ClosedRange<CGFloat>? {
if let calculatingRange = calculatingRange {
guard let initalStart = lines.first?.points.first(where: { $0.x >= calculatingRange.lowerBound &&
$0.x <= calculatingRange.upperBound }) else { return nil }
var vMin: CGFloat = initalStart.y
var vMax: CGFloat = initalStart.y
for line in lines {
if var index = line.points.firstIndex(where: { $0.x > calculatingRange.lowerBound }) {
if addBounds {
index = max(0, index - 1)
}
while index < line.points.count {
let point = line.points[index]
if point.x < calculatingRange.upperBound {
vMin = min(vMin, point.y)
vMax = max(vMax, point.y)
} else if addBounds {
vMin = min(vMin, point.y)
vMax = max(vMax, point.y)
break
} else {
break
}
index += 1
}
}
}
return vMin...vMax
} else {
guard let firstPoint = lines.first?.points.first else { return nil }
var vMin: CGFloat = firstPoint.y
var vMax: CGFloat = firstPoint.y
for line in lines {
for point in line.points {
vMin = min(vMin, point.y)
vMax = max(vMax, point.y)
}
}
return vMin...vMax
}
}
}

View file

@ -0,0 +1,132 @@
//
// PecentChartRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class PecentChartRenderer: BaseChartRenderer {
struct PercentageData {
static let blank = PecentChartRenderer.PercentageData(locations: [], components: [])
var locations: [CGFloat]
var components: [Component]
struct Component {
var color: UIColor
var values: [CGFloat]
}
}
override func setup(verticalRange: ClosedRange<CGFloat>, animated: Bool, timeFunction: TimeFunction? = nil) {
super.setup(verticalRange: 0...1, animated: animated, timeFunction: timeFunction)
}
private var componentsAnimators: [AnimationController<CGFloat>] = []
var percentageData: PercentageData = PercentageData(locations: [], components: []) {
willSet {
if percentageData.components.count != newValue.components.count {
componentsAnimators = newValue.components.map { _ in AnimationController<CGFloat>(current: 1, refreshClosure: self.refreshClosure) }
}
}
didSet {
setNeedsDisplay()
}
}
func setComponentVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
componentsAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let alpha = chartAlphaAnimator.current
guard alpha > 0 else { return }
let range = renderRange(bounds: bounds, chartFrame: chartFrame)
var paths: [CGMutablePath] = percentageData.components.map { _ in CGMutablePath() }
var vertices: [CGFloat] = Array<CGFloat>(repeating: 0, count: percentageData.components.count)
if var locationIndex = percentageData.locations.firstIndex(where: { $0 > range.lowerBound }) {
locationIndex = max(0, locationIndex - 1)
var currentLocation = transform(toChartCoordinateHorizontal: percentageData.locations[locationIndex], chartFrame: chartFrame)
let startPoint = CGPoint(x: currentLocation,
y: transform(toChartCoordinateVertical: verticalRange.current.lowerBound, chartFrame: chartFrame))
for path in paths {
path.move(to: startPoint)
}
paths.last?.addLine(to: CGPoint(x: currentLocation,
y: transform(toChartCoordinateVertical: verticalRange.current.upperBound, chartFrame: chartFrame)))
while locationIndex < percentageData.locations.count {
currentLocation = transform(toChartCoordinateHorizontal: percentageData.locations[locationIndex], chartFrame: chartFrame)
var summ: CGFloat = 0
for (index, component) in percentageData.components.enumerated() {
let visibilityPercent = componentsAnimators[index].current
let value = component.values[locationIndex] * visibilityPercent
if index == 0 {
vertices[index] = value
} else {
vertices[index] = value + vertices[index - 1]
}
summ += value
}
if summ > 0 {
for (index, value) in vertices.dropLast().enumerated() {
paths[index].addLine(to: CGPoint(x: currentLocation,
y: transform(toChartCoordinateVertical: value / summ, chartFrame: chartFrame)))
}
}
if currentLocation > range.upperBound {
break
}
locationIndex += 1
}
paths.last?.addLine(to: CGPoint(x: currentLocation,
y: transform(toChartCoordinateVertical: verticalRange.current.upperBound, chartFrame: chartFrame)))
let endPoint = CGPoint(x: currentLocation,
y: transform(toChartCoordinateVertical: verticalRange.current.lowerBound, chartFrame: chartFrame))
for (index, path) in paths.enumerated().reversed() {
let visibilityPercent = componentsAnimators[index].current
if visibilityPercent == 0 { continue }
path.addLine(to: endPoint)
path.closeSubpath()
context.saveGState()
context.beginPath()
context.addPath(path)
context.setFillColor(percentageData.components[index].color.cgColor)
context.fillPath()
context.restoreGState()
}
}
}
}
extension PecentChartRenderer.PercentageData {
static func horizontalRange(data: PecentChartRenderer.PercentageData) -> ClosedRange<CGFloat>? {
guard let firstPoint = data.locations.first,
let lastPoint = data.locations.last,
firstPoint <= lastPoint else {
return nil
}
return firstPoint...lastPoint
}
}

View file

@ -0,0 +1,202 @@
//
// PercentPieAnimationRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/13/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class PercentPieAnimationRenderer: BaseChartRenderer {
override func setup(verticalRange: ClosedRange<CGFloat>, animated: Bool, timeFunction: TimeFunction? = nil) {
super.setup(verticalRange: 0...1, animated: animated, timeFunction: timeFunction)
}
private lazy var transitionAnimator = AnimationController<CGFloat>(current: 0, refreshClosure: refreshClosure)
private var animationComponentsPoints: [[CGPoint]] = []
var visiblePercentageData: PecentChartRenderer.PercentageData = .blank {
didSet {
animationComponentsPoints = []
}
}
var visiblePieComponents: [PieChartRenderer.PieComponent] = []
func animate(fromDataToPie: Bool, animated: Bool, completion: @escaping () -> Void) {
assert(visiblePercentageData.components.count == visiblePieComponents.count)
isEnabled = true
transitionAnimator.completionClosure = { [weak self] in
self?.isEnabled = false
completion()
}
transitionAnimator.animate(to: fromDataToPie ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
private func generateAnimationComponentPoints(bounds: CGRect, chartFrame: CGRect) {
let range = renderRange(bounds: bounds, chartFrame: chartFrame)
let componentsCount = visiblePercentageData.components.count
guard componentsCount > 0 else { return }
animationComponentsPoints = visiblePercentageData.components.map { _ in [] }
var vertices: [CGFloat] = Array<CGFloat>(repeating: 0, count: visiblePercentageData.components.count)
if var locationIndex = visiblePercentageData.locations.firstIndex(where: { $0 > range.lowerBound }) {
locationIndex = max(0, locationIndex - 1)
var currentLocation = transform(toChartCoordinateHorizontal: visiblePercentageData.locations[locationIndex], chartFrame: chartFrame)
let startPoint = CGPoint(x: currentLocation, y: transform(toChartCoordinateVertical: verticalRange.current.lowerBound, chartFrame: chartFrame))
for index in 0..<componentsCount {
animationComponentsPoints[index].append(startPoint)
}
animationComponentsPoints[componentsCount - 1].append(CGPoint(x: currentLocation, y: transform(toChartCoordinateVertical: verticalRange.current.upperBound, chartFrame: chartFrame)))
while locationIndex < visiblePercentageData.locations.count {
currentLocation = transform(toChartCoordinateHorizontal: visiblePercentageData.locations[locationIndex], chartFrame: chartFrame)
var summ: CGFloat = 0
for (index, component) in visiblePercentageData.components.enumerated() {
let value = component.values[locationIndex]
if index == 0 {
vertices[index] = value
} else {
vertices[index] = value + vertices[index - 1]
}
summ += value
}
for (index, value) in vertices.dropLast().enumerated() {
animationComponentsPoints[index].append(CGPoint(x: currentLocation, y: transform(toChartCoordinateVertical: value / summ, chartFrame: chartFrame)))
}
if visiblePercentageData.locations[locationIndex] > range.upperBound {
break
}
locationIndex += 1
}
animationComponentsPoints[componentsCount - 1].append(CGPoint(x: currentLocation, y: transform(toChartCoordinateVertical: verticalRange.current.upperBound, chartFrame: chartFrame)))
let endPoint = CGPoint(x: currentLocation, y: transform(toChartCoordinateVertical: verticalRange.current.lowerBound, chartFrame: chartFrame))
for index in 0..<componentsCount {
animationComponentsPoints[index].append(endPoint)
}
}
}
private var initialPieAngle: CGFloat = .pi / 3
var backgroundColor: UIColor = .white
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
self.optimizationLevel = 1
if animationComponentsPoints.isEmpty {
generateAnimationComponentPoints(bounds: bounds, chartFrame: chartFrame)
}
let numberOfComponents = animationComponentsPoints.count
guard numberOfComponents > 0 else { return }
let destinationRadius = max(chartFrame.width, chartFrame.height)
let animationFraction = transitionAnimator.current
let animationFractionD = Double(transitionAnimator.current)
let easeInAnimationFractionD = animationFractionD * animationFractionD * animationFractionD * animationFractionD
let center = CGPoint(x: chartFrame.midX, y: chartFrame.midY)
let totalPieSumm: CGFloat = visiblePieComponents.map { $0.value } .reduce(0, +)
let pathsToDraw: [CGMutablePath] = (0..<numberOfComponents).map { _ in CGMutablePath() }
var startAngle: CGFloat = initialPieAngle
for componentIndex in 0..<(numberOfComponents - 1) {
let componentPoints = animationComponentsPoints[componentIndex]
guard componentPoints.count > 4 else {
return
}
let percent = visiblePieComponents[componentIndex].value / totalPieSumm
let segmentSize = 2 * .pi * percent
let endAngle = startAngle + segmentSize
let centerAngle = (startAngle + endAngle) / 2
let lineCenterPoint = CGPoint.valueBetween(start: componentPoints[componentPoints.count / 2],
end: center,
offset: animationFractionD)
let startDestinationPoint = lineCenterPoint + CGPoint(x: destinationRadius, y: 0)
let centerDestinationPoint = lineCenterPoint + CGPoint(x: 0, y: destinationRadius)
let endDestinationPoint = lineCenterPoint + CGPoint(x: -destinationRadius, y: 0)
let initialStartDestinationAngle: CGFloat = 0
let initialCenterDestinationAngle: CGFloat = .pi / 2
let initialEndDestinationAngle: CGFloat = .pi
var previousAddedPoint = (componentPoints[0] * 2 - center)
.rotate(origin: lineCenterPoint, angle: CGFloat.valueBetween(start: 0, end: centerAngle - initialCenterDestinationAngle, offset: animationFractionD))
pathsToDraw[componentIndex].move(to: previousAddedPoint)
func addPointToPath(_ point: CGPoint) {
if (point - previousAddedPoint).lengthSquared() > optimizationLevel {
pathsToDraw[componentIndex].addLine(to: point)
previousAddedPoint = point
}
}
for endPointIndex in 1..<(componentPoints.count / 2) {
addPointToPath(CGPoint.valueBetween(start: componentPoints[endPointIndex], end: endDestinationPoint, offset: easeInAnimationFractionD)
.rotate(origin: lineCenterPoint, angle: CGFloat.valueBetween(start: 0, end: endAngle - initialEndDestinationAngle, offset: animationFractionD)))
}
addPointToPath(lineCenterPoint)
for startPointIndex in (componentPoints.count / 2 + 1)..<(componentPoints.count - 1) {
addPointToPath(CGPoint.valueBetween(start: componentPoints[startPointIndex], end: startDestinationPoint, offset: easeInAnimationFractionD)
.rotate(origin: lineCenterPoint, angle: CGFloat.valueBetween(start: 0, end: startAngle - initialStartDestinationAngle, offset: animationFractionD)))
}
if let lastPoint = componentPoints.last {
addPointToPath((lastPoint * 2 - center)
.rotate(origin: lineCenterPoint, angle: CGFloat.valueBetween(start: 0, end: centerAngle - initialCenterDestinationAngle, offset: animationFractionD)))
}
startAngle = endAngle
}
if let lastPath = animationComponentsPoints.last {
pathsToDraw.last?.addLines(between: lastPath)
}
for (index, path) in pathsToDraw.enumerated().reversed() {
path.closeSubpath()
context.saveGState()
context.beginPath()
context.addPath(path)
context.setFillColor(visiblePieComponents[index].color.cgColor)
context.fillPath()
context.restoreGState()
}
let diagramRadius = (min(chartFrame.width, chartFrame.height) / 2) * 0.925
let targetFrame = CGRect(origin: CGPoint(x: center.x - diagramRadius,
y: center.y - diagramRadius),
size: CGSize(width: diagramRadius * 2,
height: diagramRadius * 2))
let minX = animationComponentsPoints.last?.first?.x ?? 0
let maxX = animationComponentsPoints.last?.last?.x ?? 0
let startFrame = CGRect(x: minX,
y: chartFrame.minY,
width: maxX - minX,
height: chartFrame.height)
let cornerRadius = diagramRadius * animationFraction
let fadeOutFrame = CGRect.valueBetween(start: startFrame, end: targetFrame, offset: animationFractionD)
let fadeOutPath = CGMutablePath()
fadeOutPath.addRect(bounds)
fadeOutPath.addPath(CGPath(roundedRect: fadeOutFrame, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil))
context.saveGState()
context.beginPath()
context.addPath(fadeOutPath)
context.setFillColor(backgroundColor.cgColor)
context.fillPath(using: .evenOdd)
context.restoreGState()
}
}

View file

@ -0,0 +1,31 @@
//
// PerformanceRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/10/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class PerformanceRenderer: ChartViewRenderer {
var containerViews: [UIView] = []
private var previousTickTime: TimeInterval = CACurrentMediaTime()
func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
let currentTime = CACurrentMediaTime()
let delta = currentTime - previousTickTime
previousTickTime = currentTime
let normalDelta = 0.017
let redDelta = 0.05
if delta > normalDelta || delta < 0.75 {
let green = CGFloat( 1.0 - crop(0, (delta - normalDelta) / (redDelta - normalDelta), 1))
let color = UIColor(red: 1.0, green: green, blue: 0, alpha: 1)
context.setFillColor(color.cgColor)
context.fill(CGRect(x: 0, y: 0, width: bounds.width, height: 3))
}
}
}

View file

@ -0,0 +1,191 @@
//
// PieChartRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/11/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class PieChartRenderer: BaseChartRenderer {
struct PieComponent: Hashable {
var color: UIColor
var value: CGFloat
}
override func setup(verticalRange: ClosedRange<CGFloat>, animated: Bool, timeFunction: TimeFunction? = nil) {
super.setup(verticalRange: 0...1, animated: animated, timeFunction: timeFunction)
}
var valuesFormatter: NumberFormatter = NumberFormatter()
var drawValues: Bool = true
private var componentsAnimators: [AnimationController<CGFloat>] = []
private lazy var transitionAnimator: AnimationController<CGFloat> = { AnimationController<CGFloat>(current: 1, refreshClosure: self.refreshClosure) }()
private var oldPercentageData: [PieComponent] = []
private var percentageData: [PieComponent] = []
private var setlectedSegmentsAnimators: [AnimationController<CGFloat>] = []
var drawPie: Bool = true
var initialAngle: CGFloat = .pi / 3
var hasSelectedSegments: Bool {
return selectedSegment != nil
}
private(set) var selectedSegment: Int?
func selectSegmentAt(at indexToSelect: Int?, animated: Bool) {
selectedSegment = indexToSelect
for (index, animator) in setlectedSegmentsAnimators.enumerated() {
let fraction: CGFloat = (index == indexToSelect) ? 1.0 : 0.0
if animated {
animator.animate(to: fraction, duration: .defaultDuration / 2)
} else {
animator.set(current: fraction)
}
}
}
func updatePercentageData(_ percentageData: [PieComponent], animated: Bool) {
if self.percentageData.count != percentageData.count {
componentsAnimators = percentageData.map { _ in AnimationController<CGFloat>(current: 1, refreshClosure: self.refreshClosure) }
setlectedSegmentsAnimators = percentageData.map { _ in AnimationController<CGFloat>(current: 0, refreshClosure: self.refreshClosure) }
}
if animated {
self.oldPercentageData = self.currentTransitionAnimationData
self.percentageData = percentageData
transitionAnimator.completionClosure = { [weak self] in
self?.oldPercentageData = []
}
transitionAnimator.set(current: 0)
transitionAnimator.animate(to: 1, duration: .defaultDuration)
} else {
self.oldPercentageData = []
self.percentageData = percentageData
transitionAnimator.set(current: 0)
}
}
func setComponentVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
componentsAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
var lastRenderedBounds: CGRect = .zero
var lastRenderedChartFrame: CGRect = .zero
func selectedItemIndex(at point: CGPoint) -> Int? {
let touchPosition = lastRenderedChartFrame.origin + point * lastRenderedChartFrame.size
let center = CGPoint(x: lastRenderedChartFrame.midX, y: lastRenderedChartFrame.midY)
let radius = min(lastRenderedChartFrame.width, lastRenderedChartFrame.height) / 2
if center.distanceTo(touchPosition) > radius { return nil }
let angle = (center - touchPosition).angle + .pi
let currentData = currentlyVisibleData
let total: CGFloat = currentData.map({ $0.value }).reduce(0, +)
var startAngle: CGFloat = initialAngle
for (index, piece) in currentData.enumerated() {
let percent = piece.value / total
let segmentSize = 2 * .pi * percent
let endAngle = startAngle + segmentSize
if angle >= startAngle && angle <= endAngle ||
angle + .pi * 2 >= startAngle && angle + .pi * 2 <= endAngle {
return index
}
startAngle = endAngle
}
return nil
}
private var currentTransitionAnimationData: [PieComponent] {
if transitionAnimator.isAnimating {
let animationFraction = transitionAnimator.current
return percentageData.enumerated().map { arg in
return PieComponent(color: arg.element.color,
value: oldPercentageData[arg.offset].value * (1 - animationFraction) + arg.element.value * animationFraction)
}
} else {
return percentageData
}
}
var currentlyVisibleData: [PieComponent] {
return currentTransitionAnimationData.enumerated().map { arg in
return PieComponent(color: arg.element.color,
value: arg.element.value * componentsAnimators[arg.offset].current)
}
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
lastRenderedBounds = bounds
lastRenderedChartFrame = chartFrame
let chartAlpha = chartAlphaAnimator.current
if chartAlpha == 0 { return }
let center = CGPoint(x: chartFrame.midX, y: chartFrame.midY)
let radius = min(chartFrame.width, chartFrame.height) / 2
let currentData = currentlyVisibleData
let total: CGFloat = currentData.map({ $0.value }).reduce(0, +)
guard total > 0 else {
return
}
let animationSelectionOffset: CGFloat = radius / 15
let maximumFontSize: CGFloat = radius / 7
let minimumFontSize: CGFloat = 4
let centerOffsetStartAngle = CGFloat.pi / 4
let minimumValueToDraw: CGFloat = 0.01
let diagramRadius = radius - animationSelectionOffset
let numberOfVisibleItems = currentlyVisibleData.filter { $0.value > 0 }.count
var startAngle: CGFloat = initialAngle
for (index, piece) in currentData.enumerated() {
let percent = piece.value / total
guard percent > 0 else { continue }
let segmentSize = 2 * .pi * percent * chartAlpha
let endAngle = startAngle + segmentSize
let centerAngle = (startAngle + endAngle) / 2
let labelVector = CGPoint(x: cos(centerAngle),
y: sin(centerAngle))
let selectionAnimationFraction = (numberOfVisibleItems > 1 ? setlectedSegmentsAnimators[index].current : 0)
let updatedCenter = CGPoint(x: center.x + labelVector.x * selectionAnimationFraction * animationSelectionOffset,
y: center.y + labelVector.y * selectionAnimationFraction * animationSelectionOffset)
if drawPie {
context.saveGState()
context.setFillColor(piece.color.withAlphaComponent(piece.color.alphaValue * chartAlpha).cgColor)
context.move(to: updatedCenter)
context.addArc(center: updatedCenter,
radius: radius - animationSelectionOffset,
startAngle: startAngle,
endAngle: endAngle,
clockwise: false)
context.fillPath()
context.restoreGState()
}
if drawValues && percent >= minimumValueToDraw {
context.saveGState()
let text = valuesFormatter.string(from: percent * 100)
let fraction = crop(0, segmentSize / centerOffsetStartAngle, 1)
let fontSize = (minimumFontSize + (maximumFontSize - minimumFontSize) * fraction).rounded(.up)
let labelPotisionOffset = diagramRadius / 2 + diagramRadius / 2 * (1 - fraction)
let font = UIFont.systemFont(ofSize: fontSize, weight: .bold)
let labelsEaseInColor = crop(0, chartAlpha * chartAlpha * 2 - 1, 1)
let attributes: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.white.withAlphaComponent(labelsEaseInColor),
.font: font]
let rect = (text as NSString).boundingRect(with: bounds.size,
options: .usesLineFragmentOrigin,
attributes: attributes,
context: nil)
let labelPoint = CGPoint(x: labelVector.x * labelPotisionOffset + updatedCenter.x - rect.width / 2,
y: labelVector.y * labelPotisionOffset + updatedCenter.y - rect.height / 2)
(text as NSString).draw(at: labelPoint, withAttributes: attributes)
context.restoreGState()
}
startAngle = endAngle
}
}
}

View file

@ -0,0 +1,42 @@
//
// VerticalLinesRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/8/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class VerticalLinesRenderer: BaseChartRenderer {
var values: [CGFloat] = [] {
didSet {
alphaAnimators = values.map { _ in AnimationController<CGFloat>(current: 1.0, refreshClosure: refreshClosure) }
setNeedsDisplay()
}
}
private var alphaAnimators: [AnimationController<CGFloat>] = []
var linesColor: UIColor = .black
var linesWidth: CGFloat = UIView.oneDevicePixel
func setLineVisible(_ isVisible: Bool, at index: Int, animated: Bool) {
alphaAnimators[index].animate(to: isVisible ? 1 : 0, duration: animated ? .defaultDuration : 0)
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
context.setLineWidth(linesWidth)
for (index, value) in values.enumerated() {
let alpha = alphaAnimators[index].current
if alpha == 0 { continue }
context.setStrokeColor(linesColor.withAlphaComponent(linesColor.alphaValue * alpha).cgColor)
let pointX = transform(toChartCoordinateHorizontal: value, chartFrame: chartFrame)
context.strokeLineSegments(between: [CGPoint(x: pointX, y: chartFrame.minY),
CGPoint(x: pointX, y: chartFrame.maxY)])
}
}
}

View file

@ -0,0 +1,162 @@
//
// VerticalScalesRenderer.swift
// GraphTest
//
// Created by Andrei Salavei on 4/8/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class VerticalScalesRenderer: BaseChartRenderer {
private var verticalLabelsAndLines: [LinesChartLabel] = []
private var animatedVerticalLabelsAndLines: [AnimatedLinesChartLabels] = []
private lazy var horizontalLinesAlphaAnimator: AnimationController<CGFloat> = {
return AnimationController(current: 1, refreshClosure: self.refreshClosure)
}()
var drawAxisX: Bool = true
var axisXColor: UIColor = .black
var axisXWidth: CGFloat = UIView.oneDevicePixel
var isRightAligned: Bool = false
var horizontalLinesColor: UIColor = .black {
didSet {
setNeedsDisplay()
}
}
var horizontalLinesWidth: CGFloat = UIView.oneDevicePixel
var lavelsAsisOffset: CGFloat = 6
var labelsColor: UIColor = .black {
didSet {
setNeedsDisplay()
}
}
var labelsFont: UIFont = .systemFont(ofSize: 11)
func setHorizontalLinesVisible(_ visible: Bool, animated: Bool) {
let destinationValue: CGFloat = visible ? 1 : 0
guard self.horizontalLinesAlphaAnimator.end != destinationValue else { return }
if animated {
self.horizontalLinesAlphaAnimator.animate(to: destinationValue, duration: .defaultDuration)
} else {
self.horizontalLinesAlphaAnimator.set(current: destinationValue)
}
}
func setup(verticalLimitsLabels: [LinesChartLabel], animated: Bool) {
if animated {
var labelsToKeepVisible: [LinesChartLabel] = []
let labelsToHide: [LinesChartLabel]
var labelsToShow: [LinesChartLabel] = []
for label in verticalLimitsLabels {
if verticalLabelsAndLines.contains(label) {
labelsToKeepVisible.append(label)
} else {
labelsToShow.append(label)
}
}
labelsToHide = verticalLabelsAndLines.filter { !verticalLimitsLabels.contains($0) }
animatedVerticalLabelsAndLines.removeAll(where: { $0.isAppearing })
verticalLabelsAndLines = labelsToKeepVisible
let showAnimation = AnimatedLinesChartLabels(labels: labelsToShow, alphaAnimator: AnimationController(current: 1.0, refreshClosure: refreshClosure))
showAnimation.isAppearing = true
showAnimation.alphaAnimator.set(current: 0)
showAnimation.alphaAnimator.animate(to: 1, duration: .defaultDuration)
showAnimation.alphaAnimator.completionClosure = { [weak self, weak showAnimation] in
guard let self = self, let showAnimation = showAnimation else { return }
self.animatedVerticalLabelsAndLines.removeAll(where: { $0 === showAnimation })
self.verticalLabelsAndLines = verticalLimitsLabels
}
let hideAnimation = AnimatedLinesChartLabels(labels: labelsToHide, alphaAnimator: AnimationController(current: 1.0, refreshClosure: refreshClosure))
hideAnimation.isAppearing = false
hideAnimation.alphaAnimator.set(current: 1)
hideAnimation.alphaAnimator.animate(to: 0, duration: .defaultDuration)
hideAnimation.alphaAnimator.completionClosure = { [weak self, weak hideAnimation] in
guard let self = self, let hideAnimation = hideAnimation else { return }
self.animatedVerticalLabelsAndLines.removeAll(where: { $0 === hideAnimation })
}
animatedVerticalLabelsAndLines.append(showAnimation)
animatedVerticalLabelsAndLines.append(hideAnimation)
} else {
verticalLabelsAndLines = verticalLimitsLabels
animatedVerticalLabelsAndLines = []
}
}
override func render(context: CGContext, bounds: CGRect, chartFrame: CGRect) {
guard isEnabled && verticalRange.current.distance > 0 && verticalRange.current.distance > 0 else { return }
let generalAlpha = chartAlphaAnimator.current
if generalAlpha == 0 { return }
let labelColorAlpha = labelsColor.alphaValue
func drawLines(_ labels: [LinesChartLabel], alpha: CGFloat) {
var lineSegments: [CGPoint] = []
let x0 = chartFrame.minX
let x1 = chartFrame.maxX
context.setStrokeColor(horizontalLinesColor.withAlphaComponent(horizontalLinesColor.alphaValue * alpha).cgColor)
for lineInfo in labels {
let y = transform(toChartCoordinateVertical: lineInfo.value, chartFrame: chartFrame).roundedUpToPixelGrid()
lineSegments.append(CGPoint(x: x0, y: y))
lineSegments.append(CGPoint(x: x1, y: y))
}
context.strokeLineSegments(between: lineSegments)
}
func drawVerticalLabels(_ labels: [LinesChartLabel], attributes: [NSAttributedString.Key: Any]) {
if isRightAligned {
for label in labels {
let y = transform(toChartCoordinateVertical: label.value, chartFrame: chartFrame) - labelsFont.pointSize - lavelsAsisOffset
let rect = (label.text as NSString).boundingRect(with: bounds.size,
options: .usesLineFragmentOrigin,
attributes: attributes,
context: nil)
(label.text as NSString).draw(at: CGPoint(x:chartFrame.maxX - rect.width, y: y), withAttributes: attributes)
}
} else {
for label in labels {
let y = transform(toChartCoordinateVertical: label.value, chartFrame: chartFrame) - labelsFont.pointSize - lavelsAsisOffset
(label.text as NSString).draw(at: CGPoint(x:chartFrame.minX, y: y), withAttributes: attributes)
}
}
}
let horizontalLinesAlpha = horizontalLinesAlphaAnimator.current
if horizontalLinesAlpha > 0 {
context.setLineWidth(horizontalLinesWidth)
drawLines(verticalLabelsAndLines, alpha: generalAlpha)
for animatedLabesAndLines in animatedVerticalLabelsAndLines {
drawLines(animatedLabesAndLines.labels, alpha: animatedLabesAndLines.alphaAnimator.current * generalAlpha * horizontalLinesAlpha)
}
if drawAxisX {
context.setLineWidth(axisXWidth)
context.setStrokeColor(axisXColor.withAlphaComponent(axisXColor.alphaValue * horizontalLinesAlpha * generalAlpha).cgColor)
let lineSegments: [CGPoint] = [CGPoint(x: chartFrame.minX, y: chartFrame.maxY.roundedUpToPixelGrid()),
CGPoint(x: chartFrame.maxX, y: chartFrame.maxY.roundedUpToPixelGrid())]
context.strokeLineSegments(between: lineSegments)
}
}
drawVerticalLabels(verticalLabelsAndLines, attributes: [.foregroundColor: labelsColor.withAlphaComponent(labelColorAlpha * generalAlpha),
.font: labelsFont])
for animatedLabesAndLines in animatedVerticalLabelsAndLines {
drawVerticalLabels(animatedLabesAndLines.labels,
attributes: [.foregroundColor: labelsColor.withAlphaComponent(animatedLabesAndLines.alphaAnimator.current * labelColorAlpha * generalAlpha),
.font: labelsFont])
}
}
}

View file

@ -0,0 +1,178 @@
//
// RangeAnimatedContainer.swift
// GraphTest
//
// Created by Andrei Salavei on 3/12/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
protocol Animatable {
static func valueBetween(start: Self, end: Self, offset: Double) -> Self
}
enum TimeFunction {
case linear
case easeOut
case easeIn
func profress(time: TimeInterval, duration: TimeInterval) -> TimeInterval {
switch self {
case .linear:
return time / duration
case .easeIn:
return (pow(2, 10 * (time / duration - 1)) - 0.0009765625) * 1.0009775171065499
case .easeOut:
return (-pow(2, -10 * time / duration)) + 1 * 1.0009775171065499
}
}
}
class AnimationController<AnimatableObject: Animatable> {
private(set) var isAnimating: Bool = false
private(set) var animationDuration: TimeInterval = 0.0
private(set) var currentTime: TimeInterval = 0.0
private(set) var start: AnimatableObject
private(set) var end: AnimatableObject
private(set) var current: AnimatableObject
var timeFunction: TimeFunction = .linear
var refreshClosure: (() -> Void)?
// var updateClosure: ((AnimatableObject) -> Void)?
var completionClosure: (() -> Void)?
init(current: AnimatableObject, refreshClosure: (() -> Void)?) {
self.current = current
self.start = current
self.end = current
self.refreshClosure = refreshClosure
}
func animate(to: AnimatableObject, duration: TimeInterval, timeFunction: TimeFunction = .linear) {
self.timeFunction = timeFunction
currentTime = 0
animationDuration = duration
if animationDuration > 0 {
start = current
end = to
isAnimating = true
DisplayLinkService.shared.add(listner: self)
} else {
start = to
end = to
current = to
isAnimating = false
DisplayLinkService.shared.remove(listner: self)
}
refreshClosure?()
}
func set(current: AnimatableObject) {
self.start = current
self.end = current
self.current = current
animationDuration = 0.0
currentTime = 0.0
// updateClosure?(current)
refreshClosure?()
if isAnimating {
isAnimating = false
DisplayLinkService.shared.remove(listner: self)
}
}
}
extension AnimationController: DisplayLinkListner {
func update(delta: TimeInterval) {
guard isAnimating else {
DisplayLinkService.shared.remove(listner: self)
return
}
currentTime += delta
if currentTime > animationDuration || animationDuration <= 0 {
start = end
current = end
isAnimating = false
animationDuration = 0.0
currentTime = 0.0
// updateClosure?(end)
completionClosure?()
refreshClosure?()
DisplayLinkService.shared.remove(listner: self)
} else {
let offset = timeFunction.profress(time: currentTime, duration: animationDuration)
current = AnimatableObject.valueBetween(start: start, end: end, offset: offset)
// updateClosure?(current)
refreshClosure?()
}
}
}
extension ClosedRange: Animatable where Bound: BinaryFloatingPoint {
static func valueBetween(start: ClosedRange<Bound>, end: ClosedRange<Bound>, offset: Double) -> ClosedRange<Bound> {
let castedOffset = Bound(offset)
return ClosedRange(uncheckedBounds: (lower: start.lowerBound + (end.lowerBound - start.lowerBound) * castedOffset,
upper: start.upperBound + (end.upperBound - start.upperBound) * castedOffset))
}
}
extension CGFloat: Animatable {
static func valueBetween(start: CGFloat, end: CGFloat, offset: Double) -> CGFloat {
return start + (end - start) * CGFloat(offset)
}
}
extension Double: Animatable {
static func valueBetween(start: Double, end: Double, offset: Double) -> Double {
return start + (end - start) * Double(offset)
}
}
extension Int: Animatable {
static func valueBetween(start: Int, end: Int, offset: Double) -> Int {
return start + Int(Double(end - start) * offset)
}
}
extension CGPoint: Animatable {
static func valueBetween(start: CGPoint, end: CGPoint, offset: Double) -> CGPoint {
return CGPoint(x: start.x + (end.x - start.x) * CGFloat(offset),
y: start.y + (end.y - start.y) * CGFloat(offset))
}
}
extension CGRect: Animatable {
static func valueBetween(start: CGRect, end: CGRect, offset: Double) -> CGRect {
return CGRect(x: start.origin.x + (end.origin.x - start.origin.x) * CGFloat(offset),
y: start.origin.y + (end.origin.y - start.origin.y) * CGFloat(offset),
width: start.width + (end.width - start.width) * CGFloat(offset),
height: start.height + (end.height - start.height) * CGFloat(offset))
}
}
struct UIColorContainer: Animatable {
var color: UIColor
static func valueBetween(start: UIColorContainer, end: UIColorContainer, offset: Double) -> UIColorContainer {
return UIColorContainer(color: UIColor.valueBetween(start: start.color, end: end.color, offset: offset))
}
}
extension UIColor {
static func valueBetween(start: UIColor, end: UIColor, offset: Double) -> UIColor {
let offsetF = CGFloat(offset)
let startCIColor = CIColor(color: start)
let endCIColor = CIColor(color: end)
return UIColor(red: startCIColor.red + (endCIColor.red - startCIColor.red) * offsetF,
green: startCIColor.green + (endCIColor.green - startCIColor.green) * offsetF,
blue: startCIColor.blue + (endCIColor.blue - startCIColor.blue) * offsetF,
alpha: startCIColor.alpha + (endCIColor.alpha - startCIColor.alpha) * offsetF)
}
}

View file

@ -0,0 +1,18 @@
//
// Array+Utils.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
extension Array {
func safeElement(at index: Int) -> Element? {
if index >= 0 && index < count {
return self[index]
}
return nil
}
}

View file

@ -0,0 +1,17 @@
//
// CGFloat.swift
// GraphTest
//
// Created by Andrei Salavei on 4/11/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
private let screenScale: CGFloat = UIScreen.main.scale
extension CGFloat {
func roundedUpToPixelGrid() -> CGFloat {
return (self * screenScale).rounded(.up) / screenScale
}
}

View file

@ -0,0 +1,219 @@
//
// CGPoint+Extensions.swift
// GraphTest
//
// Created by Andrei Salavei on 4/11/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
extension CGPoint {
public init(vector: CGVector) {
self.init(x: vector.dx, y: vector.dy)
}
public init(angle: CGFloat) {
self.init(x: cos(angle), y: sin(angle))
}
public mutating func offset(dx: CGFloat, dy: CGFloat) -> CGPoint {
x += dx
y += dy
return self
}
public func length() -> CGFloat {
return sqrt(x*x + y*y)
}
public func lengthSquared() -> CGFloat {
return x*x + y*y
}
func normalized() -> CGPoint {
let len = length()
return len>0 ? self / len : CGPoint.zero
}
public mutating func normalize() -> CGPoint {
self = normalized()
return self
}
public func distanceTo(_ point: CGPoint) -> CGFloat {
return (self - point).length()
}
public var angle: CGFloat {
return atan2(y, x)
}
public var cgSize: CGSize {
return CGSize(width: x, height: y)
}
func rotate(origin: CGPoint, angle: CGFloat) -> CGPoint {
let point = self - origin
let s = sin(angle)
let c = cos(angle)
return CGPoint(x: c * point.x - s * point.y,
y: s * point.x + c * point.y) + origin
}
}
extension CGSize {
public var cgPoint: CGPoint {
return CGPoint(x: width, y: height)
}
public init(point: CGPoint) {
self.init(width: point.x, height: point.y)
}
}
public func + (left: CGPoint, right: CGPoint) -> CGPoint {
return CGPoint(x: left.x + right.x, y: left.y + right.y)
}
public func += (left: inout CGPoint, right: CGPoint) {
left = left + right
}
public func + (left: CGPoint, right: CGVector) -> CGPoint {
return CGPoint(x: left.x + right.dx, y: left.y + right.dy)
}
public func += (left: inout CGPoint, right: CGVector) {
left = left + right
}
public func - (left: CGPoint, right: CGPoint) -> CGPoint { return CGPoint(x: left.x - right.x, y: left.y - right.y) }
public func - (left: CGSize, right: CGSize) -> CGSize { return CGSize(width: left.width - right.width, height: left.height - right.height) }
public func - (left: CGSize, right: CGPoint) -> CGSize { return CGSize(width: left.width - right.x, height: left.height - right.x) }
public func - (left: CGPoint, right: CGSize) -> CGPoint { return CGPoint(x: left.x - right.width, y: left.y - right.height) }
public func -= (left: inout CGPoint, right: CGPoint) {
left = left - right
}
public func - (left: CGPoint, right: CGVector) -> CGPoint {
return CGPoint(x: left.x - right.dx, y: left.y - right.dy)
}
public func -= (left: inout CGPoint, right: CGVector) {
left = left - right
}
public func *= (left: inout CGPoint, right: CGPoint) {
left = left * right
}
public func * (point: CGPoint, scalar: CGFloat) -> CGPoint { return CGPoint(x: point.x * scalar, y: point.y * scalar) }
public func * (point: CGSize, scalar: CGFloat) -> CGSize { return CGSize(width: point.width * scalar, height: point.height * scalar) }
public func *= (point: inout CGPoint, scalar: CGFloat) { point = point * scalar }
public func * (left: CGPoint, right: CGVector) -> CGPoint {
return CGPoint(x: left.x * right.dx, y: left.y * right.dy)
}
public func *= (left: inout CGPoint, right: CGVector) {
left = left * right
}
public func / (left: CGPoint, right: CGPoint) -> CGPoint { return CGPoint(x: left.x / right.x, y: left.y / right.y) }
public func / (left: CGSize, right: CGSize) -> CGSize { return CGSize(width: left.width / right.width, height: left.height / right.height) }
public func / (left: CGPoint, right: CGSize) -> CGPoint { return CGPoint(x: left.x / right.width, y: left.y / right.height) }
public func / (left: CGSize, right: CGPoint) -> CGSize { return CGSize(width: left.width / right.x, height: left.height / right.y) }
public func /= (left: inout CGPoint, right: CGPoint) { left = left / right }
public func /= (left: inout CGSize, right: CGSize) { left = left / right }
public func /= (left: inout CGSize, right: CGPoint) { left = left / right }
public func /= (left: inout CGPoint, right: CGSize) { left = left / right }
public func / (point: CGPoint, scalar: CGFloat) -> CGPoint { return CGPoint(x: point.x / scalar, y: point.y / scalar) }
public func / (point: CGSize, scalar: CGFloat) -> CGSize { return CGSize(width: point.width / scalar, height: point.height / scalar) }
public func /= (point: inout CGPoint, scalar: CGFloat) {
point = point / scalar
}
public func / (left: CGPoint, right: CGVector) -> CGPoint {
return CGPoint(x: left.x / right.dx, y: left.y / right.dy)
}
public func / (left: CGSize, right: CGVector) -> CGSize {
return CGSize(width: left.width / right.dx, height: left.height / right.dy)
}
public func /= (left: inout CGPoint, right: CGVector) {
left = left / right
}
public func * (left: CGPoint, right: CGPoint) -> CGPoint { return CGPoint(x: left.x * right.x, y: left.y * right.y) }
public func * (left: CGPoint, right: CGSize) -> CGPoint { return CGPoint(x: left.x * right.width, y: left.y * right.height) }
public func *= (left: inout CGPoint, right: CGSize) { left = left * right }
public func * (left: CGSize, right: CGSize) -> CGSize { return CGSize(width: left.width * right.width, height: left.height * right.height) }
public func *= (left: inout CGSize, right: CGSize) { left = left * right }
public func * (left: CGSize, right: CGPoint) -> CGSize { return CGSize(width: left.width * right.x, height: left.height * right.y) }
public func *= (left: inout CGSize, right: CGPoint) { left = left * right }
public func lerp(start: CGPoint, end: CGPoint, t: CGFloat) -> CGPoint {
return start + (end - start) * t
}
public func abs(_ point: CGPoint) -> CGPoint {
return CGPoint(x: abs(point.x), y: abs(point.y))
}
extension CGSize {
var isValid: Bool {
return width > 0 && height > 0 && width != .infinity && height != .infinity && width != .nan && height != .nan
}
var ratio: CGFloat {
return width / height
}
}
extension CGRect {
static var identity: CGRect {
return CGRect(x: 0, y: 0, width: 1, height: 1)
}
var center: CGPoint {
return origin + size.cgPoint / 2
}
var rounded: CGRect {
return CGRect(x: origin.x.rounded(),
y: origin.y.rounded(),
width: width.rounded(.up),
height: height.rounded(.up))
}
var mirroredVertically: CGRect {
return CGRect(x: origin.x,
y: 1.0 - (origin.y + height),
width: width,
height: height)
}
}
extension CGAffineTransform {
func inverted(with size: CGSize) -> CGAffineTransform {
var transform = self
let transformedSize = CGRect(origin: .zero, size: size).applying(transform).size
transform.tx /= transformedSize.width;
transform.ty /= transformedSize.height;
transform = transform.inverted()
transform.tx *= transformedSize.width;
transform.ty *= transformedSize.height;
return transform
}
}

View file

@ -0,0 +1,15 @@
//
// ClosedRange+Utils.swift
// GraphTest
//
// Created by Andrei Salavei on 3/11/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
extension ClosedRange where Bound: Numeric {
var distance: Bound {
return upperBound - lowerBound
}
}

View file

@ -0,0 +1,19 @@
//
// CustomNavigationController.swift
// GraphTest
//
// Created by Andrew Solovey on 15/03/2019.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
class CustomNavigationController: UINavigationController {
override var preferredStatusBarStyle: UIStatusBarStyle {
return topViewController?.preferredStatusBarStyle ?? .default
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
return topViewController?.preferredStatusBarUpdateAnimation ?? .fade
}
}

View file

@ -0,0 +1,114 @@
//
// DisplayLinkService.swift
// GraphTest
//
// Created by Andrei Salavei on 4/7/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
import CoreGraphics
public protocol DisplayLinkListner: class {
func update(delta: TimeInterval)
}
// DispatchSource mares refreshes more accurate
class DisplayLinkService {
let listners = NSHashTable<AnyObject>.weakObjects()
static let shared = DisplayLinkService()
public func add(listner: DisplayLinkListner) {
listners.add(listner)
startDisplayLink()
}
public func remove(listner: DisplayLinkListner) {
listners.remove(listner)
if listners.count == 0 {
stopDisplayLink()
}
}
// private init() {
// displayLink.add(to: .main, forMode: .common)
// displayLink.preferredFramesPerSecond = 60
// displayLink.isPaused = true
// }
//
// // MARK: - Display Link
// private lazy var displayLink: CADisplayLink! = { CADisplayLink(target: self, selector: #selector(displayLinkDidFire)) } ()
// private var previousTickTime = 0.0
//
// private func startDisplayLink() {
// guard displayLink.isPaused else {
// return
// }
// previousTickTime = CACurrentMediaTime()
// displayLink.isPaused = false
// }
//
// @objc private func displayLinkDidFire(_ displayLink: CADisplayLink) {
// let currentTime = CACurrentMediaTime()
// let delta = currentTime - previousTickTime
// previousTickTime = currentTime
// let allListners = listners.allObjects
// var hasListners = false
// for listner in allListners {
// (listner as! DisplayLinkListner).update(delta: delta)
// hasListners = true
// }
//
// if !hasListners {
// stopDisplayLink()
// }
// }
//
// private func stopDisplayLink() {
// displayLink.isPaused = true
// }
private init() {
dispatchSourceTimer.schedule(deadline: .now() + 1.0 / 60, repeating: 1.0 / 60)
dispatchSourceTimer.setEventHandler {
DispatchQueue.main.sync {
self.fire()
}
}
}
private var dispatchSourceTimer = DispatchSource.makeTimerSource(flags: [], queue: .global(qos: .userInteractive))
private var dispatchSourceTimerStarted: Bool = false
private var previousTickTime = 0.0
private func startDisplayLink() {
guard !dispatchSourceTimerStarted else { return }
dispatchSourceTimerStarted = true
previousTickTime = CACurrentMediaTime()
dispatchSourceTimer.resume()
}
private func stopDisplayLink() {
guard dispatchSourceTimerStarted else { return }
dispatchSourceTimerStarted = false
dispatchSourceTimer.suspend()
}
public func fire() {
let currentTime = CACurrentMediaTime()
let delta = currentTime - previousTickTime
previousTickTime = currentTime
let allListners = listners.allObjects
var hasListners = false
for listner in allListners {
(listner as! DisplayLinkListner).update(delta: delta)
hasListners = true
}
if !hasListners {
stopDisplayLink()
}
}
}

View file

@ -0,0 +1,12 @@
//
// GlobalHelpers.swift
// TrackingRecorder
//
// Created by Andrew Solovey on 07.09.2018.
// Copyright © 2018 Andrew Solovey. All rights reserved.
//
public func crop<Type>(_ lower: Type, _ val: Type, _ upper: Type) -> Type where Type : Comparable {
assert(lower < upper, "Invalid lover and upper values")
return max(lower, min(upper, val))
}

View file

@ -0,0 +1,19 @@
//
// NumberFormatter+Utils.swift
// GraphTest
//
// Created by Andrei Salavei on 4/12/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
extension NumberFormatter {
func string(from value: CGFloat) -> String {
return string(from: Double(value))
}
func string(from value: Double) -> String {
return string(from: NSNumber(value: Double(value))) ?? ""
}
}

View file

@ -0,0 +1,17 @@
//
// OnePixelConstraint.swift
// GraphTest
//
// Created by Andrei Salavei on 4/13/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
public class OnePixelConstrain: NSLayoutConstraint {
public override func awakeFromNib() {
super.awakeFromNib()
constant = UIView.oneDevicePixel
}
}

View file

@ -0,0 +1,32 @@
//
// ScalesNumberFormatter.swift
// GraphTest
//
// Created by Andrei Salavei on 4/13/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
private let milionsScale = "M"
private let thousandsScale = "K"
class ScalesNumberFormatter: NumberFormatter {
override func string(from number: NSNumber) -> String? {
let value = number.doubleValue
let pow = log10(value)
if pow >= 6 {
guard let string = super.string(from: NSNumber(value: value / 1_000_000)) else {
return nil
}
return string + milionsScale
} else if pow >= 4 {
guard let string = super.string(from: NSNumber(value: value / 1_000)) else {
return nil
}
return string + thousandsScale
} else {
return super.string(from: number)
}
}
}

View file

@ -0,0 +1,27 @@
//
// TimeInterval+Utils.swift
// GraphTest
//
// Created by Andrei Salavei on 3/13/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
extension TimeInterval {
static let minute: TimeInterval = 60
static let hour: TimeInterval = 60 * 60
static let day: TimeInterval = 60 * 60 * 24
static let osXDuration: TimeInterval = 0.25
static let expandAnimationDuration: TimeInterval = 0.4
static var animationDurationMultipler: Double = 1.0
static var defaultDuration: TimeInterval {
return innerDefaultDuration * animationDurationMultipler
}
private static var innerDefaultDuration: TimeInterval = osXDuration
static func setDefaultSuration(_ duration: TimeInterval) {
innerDefaultDuration = duration
}
}

View file

@ -0,0 +1,36 @@
//
// TimeZone.swift
// GraphTest
//
// Created by Andrei Salavei on 4/9/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import Foundation
extension TimeZone {
static let utc = TimeZone(secondsFromGMT: 0)!
}
extension Locale {
static let posix = Locale(identifier: "en_US_POSIX")
}
extension Calendar {
static let utc: Calendar = {
var calendar = Calendar.current
calendar.locale = Locale.posix
calendar.timeZone = TimeZone.utc
return calendar
}()
}
extension DateFormatter {
static func utc(format: String = "") -> DateFormatter {
let formatter = DateFormatter()
formatter.calendar = Calendar.utc
formatter.dateFormat = format
formatter.timeZone = TimeZone.utc
return formatter
}
}

View file

@ -0,0 +1,64 @@
//
// UIColor+Utils.swift
// GraphTest
//
// Created by Andrei Salavei on 3/11/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
extension UIColor {
public convenience init?(hexString: String) {
let r, g, b, a: CGFloat
if hexString.hasPrefix("#") {
let start = hexString.index(hexString.startIndex, offsetBy: 1)
let hexColor = String(hexString[start...])
if hexColor.count == 8 {
let scanner = Scanner(string: hexColor)
var hexNumber: UInt64 = 0
if scanner.scanHexInt64(&hexNumber) {
r = CGFloat((hexNumber & 0xff000000) >> 24) / 255
g = CGFloat((hexNumber & 0x00ff0000) >> 16) / 255
b = CGFloat((hexNumber & 0x0000ff00) >> 8) / 255
a = CGFloat(hexNumber & 0x000000ff) / 255
self.init(red: r, green: g, blue: b, alpha: a)
return
}
} else if hexColor.count == 6 {
let scanner = Scanner(string: hexColor)
var hexNumber: UInt64 = 0
if scanner.scanHexInt64(&hexNumber) {
r = CGFloat((hexNumber & 0xff0000) >> 16) / 255
g = CGFloat((hexNumber & 0x00ff00) >> 8) / 255
b = CGFloat((hexNumber & 0x0000ff) >> 0) / 255
self.init(red: r, green: g, blue: b, alpha: 1.0)
return
}
}
}
return nil
}
func image(_ size: CGSize = CGSize(width: 1, height: 1)) -> UIImage {
if #available(iOS 10.0, *) {
return UIGraphicsImageRenderer(size: size).image { rendererContext in
self.setFill()
rendererContext.fill(CGRect(origin: .zero, size: size))
}
} else {
return UIImage()
}
}
var redValue: CGFloat{ return CIColor(color: self).red }
var greenValue: CGFloat{ return CIColor(color: self).green }
var blueValue: CGFloat{ return CIColor(color: self).blue }
var alphaValue: CGFloat{ return CIColor(color: self).alpha }
}

View file

@ -0,0 +1,28 @@
//
// UIImage+Utils.swift
// GraphTest
//
// Created by Andrei Salavei on 4/8/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
extension UIImage {
static let arrowRight = UIImage(bundleImageName: "Chart/arrow_right")
static let arrowLeft = UIImage(bundleImageName: "Chart/arrow_left")
public convenience init?(color: UIColor, size: CGSize = CGSize(width: 1, height: 1)) {
let rect = CGRect(origin: .zero, size: size)
UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0)
color.setFill()
UIRectFill(rect)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
guard let cgImage = image?.cgImage else { return nil }
self.init(cgImage: cgImage)
}
}

View file

@ -0,0 +1,24 @@
//
// UIImageView+Utils.swift
// GraphTest
//
// Created by Andrei Salavei on 4/9/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
extension UIImageView {
func setImage(_ image: UIImage?, animated: Bool) {
if self.image != image {
if animated {
let animation = CATransition()
animation.timingFunction = CAMediaTimingFunction.init(name: .linear)
animation.type = .fade
animation.duration = .defaultDuration
self.layer.add(animation, forKey: "kCATransitionImageFade")
}
self.image = image
}
}
}

View file

@ -0,0 +1,37 @@
//
// UILabel+Utils.swift
// GraphTest
//
// Created by Andrei Salavei on 4/9/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
extension UILabel {
func setTextColor(_ color: UIColor, animated: Bool) {
if self.textColor != color {
if animated {
let animation = CATransition()
animation.timingFunction = CAMediaTimingFunction.init(name: .linear)
animation.type = .fade
animation.duration = .defaultDuration
self.layer.add(animation, forKey: "kCATransitionColorFade")
}
self.textColor = color
}
}
func setText(_ title: String?, animated: Bool) {
if self.text != title {
if animated {
let animation = CATransition()
animation.timingFunction = CAMediaTimingFunction.init(name: .linear)
animation.type = .fade
animation.duration = .defaultDuration
self.layer.add(animation, forKey: "kCATransitionTextFade")
}
self.text = title
}
}
}

View file

@ -0,0 +1,57 @@
//
// UIView+Extensions.swift
// GraphTest
//
// Created by Andrei Salavei on 4/10/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
extension UIView {
static let oneDevicePixel: CGFloat = (1.0 / max(2, min(1, UIScreen.main.scale)))
}
// MARK: UIView+Animation
public extension UIView {
func bringToFront() {
superview?.bringSubviewToFront(self)
}
func layoutIfNeeded(animated: Bool) {
UIView.perform(animated: animated) {
self.layoutIfNeeded()
}
}
func setVisible(_ visible: Bool, animated: Bool) {
let updatedAlpha: CGFloat = visible ? 1 : 0
if self.alpha != updatedAlpha {
UIView.perform(animated: animated) {
self.alpha = updatedAlpha
}
}
}
static func perform(animated: Bool, animations: @escaping () -> Void) {
perform(animated: animated, animations: animations, completion: { _ in })
}
static func perform(animated: Bool, animations: @escaping () -> Void, completion: @escaping (Bool) -> Void) {
if animated {
UIView.animate(withDuration: .defaultDuration, delay: 0, animations: animations, completion: completion)
} else {
animations()
completion(true)
}
}
var isVisibleInWindow: Bool {
guard let windowBounds = window?.bounds else {
return false
}
let frame = convert(bounds, to: nil)
return frame.intersects(windowBounds)
}
}

View file

@ -0,0 +1,76 @@
//
// ChartLineData.swift
// GraphTest
//
// Created by Andrei Salavei on 3/13/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
struct ChartLineData {
var title: String
var color: UIColor
var width: CGFloat?
var points: [CGPoint]
}
extension ChartLineData {
static func horizontalRange(lines: [ChartLineData]) -> ClosedRange<CGFloat>? {
guard let firstPoint = lines.first?.points.first else { return nil }
var hMin: CGFloat = firstPoint.x
var hMax: CGFloat = firstPoint.x
for line in lines {
if let first = line.points.first,
let last = line.points.last {
hMin = min(hMin, first.x)
hMax = max(hMax, last.x)
}
}
return hMin...hMax
}
static func verticalRange(lines: [ChartLineData], calculatingRange: ClosedRange<CGFloat>? = nil, addBounds: Bool = false) -> ClosedRange<CGFloat>? {
if let calculatingRange = calculatingRange {
guard let initalStart = lines.first?.points.first(where: { $0.x > calculatingRange.lowerBound &&
$0.x < calculatingRange.upperBound }) else { return nil }
var vMin: CGFloat = initalStart.y
var vMax: CGFloat = initalStart.y
for line in lines {
if var index = line.points.firstIndex(where: { $0.x > calculatingRange.lowerBound }) {
if addBounds {
index = max(0, index - 1)
}
while index < line.points.count {
let point = line.points[index]
if point.x < calculatingRange.upperBound {
vMin = min(vMin, point.y)
vMax = max(vMax, point.y)
} else if addBounds {
vMin = min(vMin, point.y)
vMax = max(vMax, point.y)
break
} else {
break
}
index += 1
}
}
}
return vMin...vMax
} else {
guard let firstPoint = lines.first?.points.first else { return nil }
var vMin: CGFloat = firstPoint.y
var vMax: CGFloat = firstPoint.y
for line in lines {
for point in line.points {
vMin = min(vMin, point.y)
vMax = max(vMax, point.y)
}
}
return vMin...vMax
}
}
}

View file

@ -0,0 +1,175 @@
//
// ColorMode.swift
// GraphTest
//
// Created by Andrew Solovey on 15/03/2019.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
import AppBundle
protocol ColorModeContainer {
func apply(colorMode: ColorMode, animated: Bool)
}
enum ColorMode {
case day
case night
}
extension ColorMode {
var chartTitleColor: UIColor { // Текст с датой на чарте
switch self {
case .day: return .black
case .night: return .white
}
}
var actionButtonColor: UIColor { // Кнопка Zoom Out/ Смена режима день/ночь
switch self {
case .day: return UIColor(red: 53/255.0, green: 120/255.0, blue: 246/255.0, alpha: 1.0)
case .night: return UIColor(red: 84/255.0, green: 164/255.0, blue: 247/255.0, alpha: 1.0)
}
}
var tableBackgroundColor: UIColor {
switch self {
case .day: return UIColor(red: 239/255.0, green: 239/255.0, blue: 244/255.0, alpha: 1.0)
case .night: return UIColor(red: 24/255.0, green: 34/255.0, blue: 45/255.0, alpha: 1.0)
}
}
var chartBackgroundColor: UIColor {
switch self {
case .day: return UIColor(red: 254/255.0, green: 254/255.0, blue: 254/255.0, alpha: 1.0)
case .night: return UIColor(red: 34/255.0, green: 47/255.0, blue: 63/255.0, alpha: 1.0)
}
}
var sectionTitleColor: UIColor {
switch self {
case .day: return UIColor(red: 109/255.0, green: 109/255.0, blue: 114/255.0, alpha: 1.0)
case .night: return UIColor(red: 133/255.0, green: 150/255.0, blue: 171/255.0, alpha: 1.0)
}
}
var tableSeparatorColor: UIColor {
switch self {
case .day: return UIColor(red: 200/255.0, green: 199/255.0, blue: 204/255.0, alpha: 1.0)
case .night: return UIColor(red: 18/255.0, green: 26/255.0, blue: 35/255.0, alpha: 1.0)
}
}
var chartLabelsColor: UIColor {
switch self {
case .day: return UIColor(red: 37/255.0, green: 37/255.0, blue: 41/255.0, alpha: 0.5)
case .night: return UIColor(red: 186/255.0, green: 204/255.0, blue: 225/255.0, alpha: 0.6)
}
}
var chartHelperLinesColor: UIColor {
switch self {
case .day: return UIColor(red: 24/255.0, green: 45/255.0, blue: 59/255.0, alpha: 0.1)
case .night: return UIColor(red: 133/255.0, green: 150/255.0, blue: 171/255.0, alpha: 0.20)
}
}
var chartStrongLinesColor: UIColor {
switch self {
case .day: return UIColor(red: 24/255.0, green: 45/255.0, blue: 59/255.0, alpha: 0.35)
case .night: return UIColor(red: 186/255.0, green: 204/255.0, blue: 225/255.0, alpha: 0.45)
}
}
var barChartStrongLinesColor: UIColor {
switch self {
case .day: return UIColor(red: 37/255.0, green: 37/255.0, blue: 41/255.0, alpha: 0.2)
case .night: return UIColor(red: 186/255.0, green: 204/255.0, blue: 225/255.0, alpha: 0.45)
}
}
var chartDetailsTextColor: UIColor {
switch self {
case .day: return UIColor(red: 109/255.0, green: 109/255.0, blue: 114/255.0, alpha: 1.0)
case .night: return UIColor(red: 254/255.0, green: 254/255.0, blue: 254/255.0, alpha: 1.0)
}
}
var chartDetailsArrowColor: UIColor {
switch self {
case .day: return UIColor(red: 197/255.0, green: 199/255.0, blue: 205/255.0, alpha: 1.0)
case .night: return UIColor(red: 76/255.0, green: 84/255.0, blue: 96/255.0, alpha: 1.0)
}
}
var chartDetailsViewColor: UIColor {
switch self {
case .day: return UIColor(red: 245/255.0, green: 245/255.0, blue: 251/255.0, alpha: 1.0)
case .night: return UIColor(red: 25/255.0, green: 35/255.0, blue: 47/255.0, alpha: 1.0)
}
}
var descriptionChatNameColor: UIColor {
switch self {
case .day: return .black
case .night: return UIColor(red: 254/255.0, green: 254/255.0, blue: 254/255.0, alpha: 1.0)
}
}
var descriptionActionColor: UIColor {
switch self {
case .day: return UIColor(red: 1/255.0, green: 125/255.0, blue: 229/255.0, alpha: 1.0)
case .night: return UIColor(red: 24/255.0, green: 145/255.0, blue: 255/255.0, alpha: 1.0)
}
}
var rangeViewBackgroundColor: UIColor {
switch self {
case .day: return UIColor(red: 254/255.0, green: 254/255.0, blue: 254/255.0, alpha: 1.0)
case .night: return UIColor(red: 34/255.0, green: 47/255.0, blue: 63/255.0, alpha: 1.0)
}
}
var rangeViewFrameColor: UIColor {
switch self {
case .day: return UIColor(red: 202/255.0, green: 212/255.0, blue: 222/255.0, alpha: 1.0)
case .night: return UIColor(red: 53/255.0, green: 70/255.0, blue: 89/255.0, alpha: 1.0)
}
}
var rangeViewTintColor: UIColor {
switch self {
case .day: return UIColor(red: 239/255.0, green: 239/255.0, blue: 244/255.0, alpha: 0.5)
case .night: return UIColor(red: 24/255.0, green: 34/255.0, blue: 45/255.0, alpha: 0.5)
}
}
var rangeViewMarkerColor: UIColor {
switch self {
case .day: return UIColor.white
case .night: return UIColor.white
}
}
var statusBarStyle: UIStatusBarStyle {
switch self {
case .day: return .default
case .night: return .lightContent
}
}
var viewTintColor: UIColor {
switch self {
case .day: return .black
case .night: return UIColor(red: 254/255.0, green: 254/255.0, blue: 254/255.0, alpha: 1.0)
}
}
var rangeCropImage: UIImage? {
switch self {
case .day: return UIImage(bundleImageName: "Chart/selection_frame_light")
case .night: return UIImage(bundleImageName: "Chart/selection_frame_dark")
}
}
}

View file

@ -0,0 +1,25 @@
//
// LinesChartLabel.swift
// GraphTest
//
// Created by Andrei Salavei on 3/18/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
struct LinesChartLabel: Hashable {
let value: CGFloat
let text: String
}
class AnimatedLinesChartLabels {
var labels: [LinesChartLabel]
var isAppearing: Bool = false
let alphaAnimator: AnimationController<CGFloat>
init(labels: [LinesChartLabel], alphaAnimator: AnimationController<CGFloat>) {
self.labels = labels
self.alphaAnimator = alphaAnimator
}
}

View file

@ -0,0 +1,15 @@
//
// LinesSelectionLabel.swift
// GraphTest
//
// Created by Andrei Salavei on 3/18/19.
// Copyright © 2019 Andrei Salavei. All rights reserved.
//
import UIKit
struct LinesSelectionLabel {
let coordinate: CGPoint
let valueText: String
let color: UIColor
}

View file

@ -22,10 +22,6 @@ import AppBundle
import LocalizedPeerData
import TelegramIntents
public func useSpecialTabBarIcons() -> Bool {
return (Date(timeIntervalSince1970: 1545642000)...Date(timeIntervalSince1970: 1546387200)).contains(Date())
}
private func fixListNodeScrolling(_ listNode: ListView, searchNode: NavigationBarSearchContentNode) -> Bool {
if searchNode.expansionProgress > 0.0 && searchNode.expansionProgress < 1.0 {
let scrollToItem: ListViewScrollToItem
@ -182,8 +178,8 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController,
self.tabBarItem.title = self.presentationData.strings.DialogList_Title
let icon: UIImage?
if (useSpecialTabBarIcons()) {
icon = UIImage(bundleImageName: "Chat List/Tabs/NY/IconChats")
if useSpecialTabBarIcons() {
icon = UIImage(bundleImageName: "Chat List/Tabs/Holiday/IconChats")
} else {
icon = UIImage(bundleImageName: "Chat List/Tabs/IconChats")
}

View file

@ -727,7 +727,7 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo
return (views, local)
}
}
|> mapToSignal{ viewsAndPeers -> Signal<(peers: [RenderedPeer], unread: [PeerId: (Int32, Bool)]), NoError> in
|> mapToSignal { viewsAndPeers -> Signal<(peers: [RenderedPeer], unread: [PeerId: (Int32, Bool)]), NoError> in
return context.account.postbox.unreadMessageCountsView(items: viewsAndPeers.0.map {.peer($0.peerId)}) |> map { values in
var unread: [PeerId: (Int32, Bool)] = [:]
for peerView in viewsAndPeers.0 {
@ -816,8 +816,18 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo
)
}
return combineLatest(accountPeer, foundLocalPeers, foundRemotePeers, foundRemoteMessages, presentationDataPromise.get(), searchStatePromise.get())
|> map { accountPeer, foundLocalPeers, foundRemotePeers, foundRemoteMessages, presentationData, searchState -> ([ChatListSearchEntry], Bool)? in
let resolvedMessage = .single(nil)
|> then(context.sharedContext.resolveUrl(account: context.account, url: query)
|> mapToSignal { resolvedUrl -> Signal<Message?, NoError> in
if case let .channelMessage(peerId, messageId) = resolvedUrl {
return downloadMessage(postbox: context.account.postbox, network: context.account.network, messageId: messageId)
} else {
return .single(nil)
}
})
return combineLatest(accountPeer, foundLocalPeers, foundRemotePeers, foundRemoteMessages, presentationDataPromise.get(), searchStatePromise.get(), resolvedMessage)
|> map { accountPeer, foundLocalPeers, foundRemotePeers, foundRemoteMessages, presentationData, searchState, resolvedMessage -> ([ChatListSearchEntry], Bool)? in
var entries: [ChatListSearchEntry] = []
let isSearching = foundRemotePeers.2 || foundRemoteMessages.1
var index = 0
@ -950,6 +960,17 @@ public final class ChatListSearchContainerNode: SearchDisplayControllerContentNo
}
}
if let message = resolvedMessage {
var peer = RenderedPeer(message: message)
if let group = message.peers[message.id.peerId] as? TelegramGroup, let migrationReference = group.migrationReference {
if let channelPeer = message.peers[migrationReference.peerId] {
peer = RenderedPeer(peer: channelPeer)
}
}
entries.append(.message(message, peer, nil, presentationData))
index += 1
}
if !foundRemotePeers.2 {
index = 0
for message in foundRemoteMessages.0.0 {

View file

@ -105,7 +105,12 @@ public class ContactsController: ViewController {
self.title = self.presentationData.strings.Contacts_Title
self.tabBarItem.title = self.presentationData.strings.Contacts_Title
let icon = UIImage(bundleImageName: "Chat List/Tabs/IconContacts")
let icon: UIImage?
if useSpecialTabBarIcons() {
icon = UIImage(bundleImageName: "Chat List/Tabs/Holiday/IconContacts")
} else {
icon = UIImage(bundleImageName: "Chat List/Tabs/IconContacts")
}
self.tabBarItem.image = icon
self.tabBarItem.selectedImage = icon

View file

@ -442,7 +442,7 @@ class TabBarNode: ASDisplayNode {
if horizontal {
backgroundFrame = CGRect(origin: CGPoint(x: originX + 15.0, y: 3.0), size: backgroundSize)
} else {
let contentWidth = node.contentWidth ?? node.frame.width
let contentWidth: CGFloat = 25.0 //node.contentWidth ?? node.frame.width
backgroundFrame = CGRect(origin: CGPoint(x: floor(originX + node.frame.width / 2.0) + contentWidth - backgroundSize.width - 5.0, y: self.centered ? 9.0 : 2.0), size: backgroundSize)
}
transition.updateFrame(node: container.badgeContainerNode, frame: backgroundFrame)

View file

@ -332,7 +332,7 @@ public class GalleryController: ViewController, StandalonePresentableController
private let centralItemRightBarButtonItem = Promise<UIBarButtonItem?>()
private let centralItemRightBarButtonItems = Promise<[UIBarButtonItem]?>(nil)
private let centralItemNavigationStyle = Promise<GalleryItemNodeNavigationStyle>()
private let centralItemFooterContentNode = Promise<GalleryFooterContentNode?>()
private let centralItemFooterContentNode = Promise<(GalleryFooterContentNode?, GalleryOverlayContentNode?)>()
private let centralItemAttributesDisposable = DisposableSet();
private let _hiddenMedia = Promise<(MessageId, Media)?>(nil)
@ -531,9 +531,9 @@ public class GalleryController: ViewController, StandalonePresentableController
}
}))
self.centralItemAttributesDisposable.add(self.centralItemFooterContentNode.get().start(next: { [weak self] footerContentNode in
self.centralItemAttributesDisposable.add(self.centralItemFooterContentNode.get().start(next: { [weak self] footerContentNode, overlayContentNode in
self?.galleryNode.updatePresentationState({
$0.withUpdatedFooterContentNode(footerContentNode)
$0.withUpdatedFooterContentNode(footerContentNode).withUpdatedOverlayContentNode(overlayContentNode)
}, transition: .immediate)
}))

View file

@ -250,8 +250,8 @@ open class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate, UIGesture
self.updateThumbnailContainerNodeAlpha(transition)
}
self.footerNode.updateLayout(layout, footerContentNode: self.presentationState.footerContentNode, thumbnailPanelHeight: thumbnailPanelHeight, transition: transition)
self.footerNode.updateLayout(layout, footerContentNode: self.presentationState.footerContentNode, overlayContentNode: self.presentationState.overlayContentNode, thumbnailPanelHeight: thumbnailPanelHeight, transition: transition)
let previousContentHeight = self.scrollView.contentSize.height
let previousVerticalOffset = self.scrollView.contentOffset.y
@ -276,14 +276,14 @@ open class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate, UIGesture
let alpha: CGFloat = self.areControlsHidden ? 0.0 : 1.0
self.navigationBar?.alpha = alpha
self.statusBar?.updateAlpha(alpha, transition: .animated(duration: 0.3, curve: .easeInOut))
self.footerNode.alpha = alpha
self.footerNode.setVisibilityAlpha(alpha)
self.updateThumbnailContainerNodeAlpha(.immediate)
})
} else {
let alpha: CGFloat = self.areControlsHidden ? 0.0 : 1.0
self.navigationBar?.alpha = alpha
self.statusBar?.updateAlpha(alpha, transition: .immediate)
self.footerNode.alpha = alpha
self.footerNode.setVisibilityAlpha(alpha)
self.updateThumbnailContainerNodeAlpha(.immediate)
}
}

View file

@ -2,16 +2,23 @@ import Foundation
public final class GalleryControllerPresentationState {
public let footerContentNode: GalleryFooterContentNode?
public let overlayContentNode: GalleryOverlayContentNode?
public init() {
self.footerContentNode = nil
self.overlayContentNode = nil
}
public init(footerContentNode: GalleryFooterContentNode?) {
public init(footerContentNode: GalleryFooterContentNode?, overlayContentNode: GalleryOverlayContentNode?) {
self.footerContentNode = footerContentNode
self.overlayContentNode = overlayContentNode
}
public func withUpdatedFooterContentNode(_ footerContentNode: GalleryFooterContentNode?) -> GalleryControllerPresentationState {
return GalleryControllerPresentationState(footerContentNode: footerContentNode)
return GalleryControllerPresentationState(footerContentNode: footerContentNode, overlayContentNode: self.overlayContentNode)
}
public func withUpdatedOverlayContentNode(_ overlayContentNode: GalleryOverlayContentNode?) -> GalleryControllerPresentationState {
return GalleryControllerPresentationState(footerContentNode: self.footerContentNode, overlayContentNode: overlayContentNode)
}
}

View file

@ -31,3 +31,20 @@ open class GalleryFooterContentNode: ASDisplayNode {
completion()
}
}
open class GalleryOverlayContentNode: ASDisplayNode {
var visibilityAlpha: CGFloat = 1.0
open func setVisibilityAlpha(_ alpha: CGFloat) {
self.visibilityAlpha = alpha
}
open func updateLayout(size: CGSize, metrics: LayoutMetrics, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) {
}
open func animateIn(previousContentNode: GalleryOverlayContentNode?, transition: ContainedViewLayoutTransition) {
}
open func animateOut(nextContentNode: GalleryOverlayContentNode?, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
completion()
}
}

View file

@ -7,6 +7,7 @@ public final class GalleryFooterNode: ASDisplayNode {
private let backgroundNode: ASDisplayNode
private var currentFooterContentNode: GalleryFooterContentNode?
private var currentOverlayContentNode: GalleryOverlayContentNode?
private var currentLayout: (ContainerViewLayout, CGFloat)?
private let controllerInteraction: GalleryControllerInteraction
@ -22,38 +23,59 @@ public final class GalleryFooterNode: ASDisplayNode {
self.addSubnode(self.backgroundNode)
}
public func updateLayout(_ layout: ContainerViewLayout, footerContentNode: GalleryFooterContentNode?, thumbnailPanelHeight: CGFloat, transition: ContainedViewLayoutTransition) {
private var visibilityAlpha: CGFloat = 1.0
public func setVisibilityAlpha(_ alpha: CGFloat) {
self.visibilityAlpha = alpha
self.backgroundNode.alpha = alpha
self.currentFooterContentNode?.alpha = alpha
self.currentOverlayContentNode?.setVisibilityAlpha(alpha)
}
public func updateLayout(_ layout: ContainerViewLayout, footerContentNode: GalleryFooterContentNode?, overlayContentNode: GalleryOverlayContentNode?, thumbnailPanelHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.currentLayout = (layout, thumbnailPanelHeight)
let cleanInsets = layout.insets(options: [])
var removeCurrentFooterContentNode: GalleryFooterContentNode?
var dismissedCurrentFooterContentNode: GalleryFooterContentNode?
if self.currentFooterContentNode !== footerContentNode {
if let currentFooterContentNode = self.currentFooterContentNode {
currentFooterContentNode.requestLayout = nil
removeCurrentFooterContentNode = currentFooterContentNode
dismissedCurrentFooterContentNode = currentFooterContentNode
}
self.currentFooterContentNode = footerContentNode
if let footerContentNode = footerContentNode {
footerContentNode.alpha = self.visibilityAlpha
footerContentNode.controllerInteraction = self.controllerInteraction
footerContentNode.requestLayout = { [weak self] transition in
if let strongSelf = self, let (currentLayout, currentThumbnailPanelHeight) = strongSelf.currentLayout {
strongSelf.updateLayout(currentLayout, footerContentNode: strongSelf.currentFooterContentNode, thumbnailPanelHeight: currentThumbnailPanelHeight, transition: transition)
strongSelf.updateLayout(currentLayout, footerContentNode: strongSelf.currentFooterContentNode, overlayContentNode: strongSelf.currentOverlayContentNode, thumbnailPanelHeight: currentThumbnailPanelHeight, transition: transition)
}
}
self.addSubnode(footerContentNode)
}
}
var dismissedCurrentOverlayContentNode: GalleryOverlayContentNode?
if self.currentOverlayContentNode !== overlayContentNode {
if let currentOverlayContentNode = self.currentOverlayContentNode {
dismissedCurrentOverlayContentNode = currentOverlayContentNode
}
self.currentOverlayContentNode = overlayContentNode
if let overlayContentNode = overlayContentNode {
overlayContentNode.setVisibilityAlpha(self.visibilityAlpha)
self.addSubnode(overlayContentNode)
}
}
var backgroundHeight: CGFloat = 0.0
if let footerContentNode = self.currentFooterContentNode {
backgroundHeight = footerContentNode.updateLayout(size: layout.size, metrics: layout.metrics, leftInset: layout.safeInsets.left, rightInset: layout.safeInsets.right, bottomInset: cleanInsets.bottom, contentInset: thumbnailPanelHeight, transition: transition)
transition.updateFrame(node: footerContentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - backgroundHeight), size: CGSize(width: layout.size.width, height: backgroundHeight)))
if let removeCurrentFooterContentNode = removeCurrentFooterContentNode {
if let dismissedCurrentFooterContentNode = dismissedCurrentFooterContentNode {
let contentTransition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
footerContentNode.animateIn(fromHeight: removeCurrentFooterContentNode.bounds.height, previousContentNode: removeCurrentFooterContentNode, transition: contentTransition)
removeCurrentFooterContentNode.animateOut(toHeight: backgroundHeight, nextContentNode: footerContentNode, transition: contentTransition, completion: { [weak self, weak removeCurrentFooterContentNode] in
if let strongSelf = self, let removeCurrentFooterContentNode = removeCurrentFooterContentNode, removeCurrentFooterContentNode !== strongSelf.currentFooterContentNode {
removeCurrentFooterContentNode.removeFromSupernode()
footerContentNode.animateIn(fromHeight: dismissedCurrentFooterContentNode.bounds.height, previousContentNode: dismissedCurrentFooterContentNode, transition: contentTransition)
dismissedCurrentFooterContentNode.animateOut(toHeight: backgroundHeight, nextContentNode: footerContentNode, transition: contentTransition, completion: { [weak self, weak dismissedCurrentFooterContentNode] in
if let strongSelf = self, let dismissedCurrentFooterContentNode = dismissedCurrentFooterContentNode, dismissedCurrentFooterContentNode !== strongSelf.currentFooterContentNode {
dismissedCurrentFooterContentNode.removeFromSupernode()
}
})
contentTransition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - backgroundHeight), size: CGSize(width: layout.size.width, height: backgroundHeight)))
@ -61,15 +83,41 @@ public final class GalleryFooterNode: ASDisplayNode {
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - backgroundHeight), size: CGSize(width: layout.size.width, height: backgroundHeight)))
}
} else {
if let removeCurrentFooterContentNode = removeCurrentFooterContentNode {
removeCurrentFooterContentNode.removeFromSupernode()
if let dismissedCurrentFooterContentNode = dismissedCurrentFooterContentNode {
dismissedCurrentFooterContentNode.removeFromSupernode()
}
transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - backgroundHeight), size: CGSize(width: layout.size.width, height: backgroundHeight)))
}
let contentTransition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring)
if let overlayContentNode = self.currentOverlayContentNode {
overlayContentNode.updateLayout(size: layout.size, metrics: layout.metrics, leftInset: layout.safeInsets.left, rightInset: layout.safeInsets.right, bottomInset: backgroundHeight, transition: transition)
transition.updateFrame(node: overlayContentNode, frame: CGRect(origin: CGPoint(), size: layout.size))
overlayContentNode.animateIn(previousContentNode: dismissedCurrentOverlayContentNode, transition: contentTransition)
if let dismissedCurrentOverlayContentNode = dismissedCurrentOverlayContentNode {
dismissedCurrentOverlayContentNode.animateOut(nextContentNode: overlayContentNode, transition: contentTransition, completion: { [weak self, weak dismissedCurrentOverlayContentNode] in
if let strongSelf = self, let dismissedCurrentOverlayContentNode = dismissedCurrentOverlayContentNode, dismissedCurrentOverlayContentNode !== strongSelf.currentOverlayContentNode {
dismissedCurrentOverlayContentNode.removeFromSupernode()
}
})
}
} else {
if let dismissedCurrentOverlayContentNode = dismissedCurrentOverlayContentNode {
dismissedCurrentOverlayContentNode.animateOut(nextContentNode: overlayContentNode, transition: contentTransition, completion: { [weak self, weak dismissedCurrentOverlayContentNode] in
if let strongSelf = self, let dismissedCurrentOverlayContentNode = dismissedCurrentOverlayContentNode, dismissedCurrentOverlayContentNode !== strongSelf.currentOverlayContentNode {
dismissedCurrentOverlayContentNode.removeFromSupernode()
}
})
}
}
}
override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let overlayResult = self.currentOverlayContentNode?.hitTest(point, with: event) {
return overlayResult
}
if !self.backgroundNode.frame.contains(point) {
return nil
}

View file

@ -21,6 +21,8 @@ open class GalleryItemNode: ASDisplayNode {
}
public var toggleControlsVisibility: () -> Void = { }
public var goToPreviousItem: () -> Void = { }
public var goToNextItem: () -> Void = { }
public var dismiss: () -> Void = { }
public var beginCustomDismiss: () -> Void = { }
public var completeCustomDismiss: () -> Void = { }
@ -54,8 +56,8 @@ open class GalleryItemNode: ASDisplayNode {
return .single(nil)
}
open func footerContent() -> Signal<GalleryFooterContentNode?, NoError> {
return .single(nil)
open func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((nil, nil))
}
open func navigationStyle() -> Signal<GalleryItemNodeNavigationStyle, NoError> {

View file

@ -241,6 +241,20 @@ public final class GalleryPagerNode: ASDisplayNode, UIScrollViewDelegate {
private func makeNodeForItem(at index: Int) -> GalleryItemNode {
let node = self.items[index].node()
node.toggleControlsVisibility = self.toggleControlsVisibility
node.goToPreviousItem = { [weak self] in
if let strongSelf = self {
if let index = strongSelf.centralItemIndex, index > 0 {
strongSelf.transaction(GalleryPagerTransaction(deleteItems: [], insertItems: [], updateItems: [], focusOnItem: index - 1))
}
}
}
node.goToNextItem = { [weak self] in
if let strongSelf = self {
if let index = strongSelf.centralItemIndex, index < strongSelf.items.count - 1 {
strongSelf.transaction(GalleryPagerTransaction(deleteItems: [], insertItems: [], updateItems: [], focusOnItem: index + 1))
}
}
}
node.dismiss = self.dismiss
node.beginCustomDismiss = self.beginCustomDismiss
node.completeCustomDismiss = self.completeCustomDismiss
@ -314,7 +328,7 @@ public final class GalleryPagerNode: ASDisplayNode, UIScrollViewDelegate {
}
}
if centralItemIndex != items.count - 1 {
if centralItemIndex != self.items.count - 1 {
if self.shouldLoadItems(force: forceLoad) && self.visibleItemNode(at: centralItemIndex + 1) == nil {
let node = self.makeNodeForItem(at: centralItemIndex + 1)
node.frame = centralItemNode.frame.offsetBy(dx: centralItemNode.frame.size.width + self.pageGap, dy: 0.0)

View file

@ -325,8 +325,8 @@ final class ChatAnimationGalleryItemNode: ZoomableContentGalleryItemNode {
return self._rightBarButtonItems.get()
}
override func footerContent() -> Signal<GalleryFooterContentNode?, NoError> {
return .single(self.footerContentNode)
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil))
}
@objc func statusPressed() {

View file

@ -375,8 +375,8 @@ class ChatDocumentGalleryItemNode: ZoomableContentGalleryItemNode, WKNavigationD
self.statusNodeContainer.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue, removeOnCompletion: false)
}
override func footerContent() -> Signal<GalleryFooterContentNode?, NoError> {
return .single(self.footerContentNode)
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil))
}
@objc func statusPressed() {

View file

@ -310,8 +310,8 @@ class ChatExternalFileGalleryItemNode: GalleryItemNode {
self.statusNodeContainer.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue, removeOnCompletion: false)
}
override func footerContent() -> Signal<GalleryFooterContentNode?, NoError> {
return .single(self.footerContentNode)
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil))
}
@objc func statusPressed() {

View file

@ -505,8 +505,8 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode {
return self._rightBarButtonItem.get()
}
override func footerContent() -> Signal<GalleryFooterContentNode?, NoError> {
return .single(self.footerContentNode)
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil))
}
@objc func statusPressed() {

View file

@ -145,6 +145,87 @@ private final class UniversalVideoGalleryItemPictureInPictureNode: ASDisplayNode
}
}
private let soundOnImage = generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/SoundOn"), color: .white)
private let soundOffImage = generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/SoundOff"), color: .white)
private var roundButtonBackgroundImage = {
return generateImage(CGSize(width: 42.0, height: 42), rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
context.setFillColor(UIColor(white: 0.0, alpha: 0.5).cgColor)
context.fillEllipse(in: bounds)
})
}()
private final class UniversalVideoGalleryItemOverlayNode: GalleryOverlayContentNode {
private let soundButtonNode: HighlightableButtonNode
private var validLayout: (CGSize, LayoutMetrics, CGFloat, CGFloat, CGFloat)?
override init() {
self.soundButtonNode = HighlightableButtonNode()
self.soundButtonNode.alpha = 0.0
self.soundButtonNode.setBackgroundImage(roundButtonBackgroundImage, for: .normal)
self.soundButtonNode.setImage(soundOffImage, for: .normal)
self.soundButtonNode.setImage(soundOnImage, for: .selected)
self.soundButtonNode.setImage(soundOnImage, for: [.selected, .highlighted])
super.init()
self.soundButtonNode.addTarget(self, action: #selector(self.soundButtonPressed), forControlEvents: .touchUpInside)
self.addSubnode(self.soundButtonNode)
}
func hide() {
self.soundButtonNode.isHidden = true
}
override func updateLayout(size: CGSize, metrics: LayoutMetrics, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) {
self.validLayout = (size, metrics, leftInset, rightInset, bottomInset)
let soundButtonDiameter: CGFloat = 42.0
let inset: CGFloat = 12.0
let effectiveBottomInset = self.visibilityAlpha < 1.0 ? 0.0 : bottomInset
let soundButtonFrame = CGRect(origin: CGPoint(x: size.width - soundButtonDiameter - inset - rightInset, y: size.height - soundButtonDiameter - inset - effectiveBottomInset), size: CGSize(width: soundButtonDiameter, height: soundButtonDiameter))
transition.updateFrame(node: self.soundButtonNode, frame: soundButtonFrame)
}
override func animateIn(previousContentNode: GalleryOverlayContentNode?, transition: ContainedViewLayoutTransition) {
transition.updateAlpha(node: self.soundButtonNode, alpha: 1.0)
}
override func animateOut(nextContentNode: GalleryOverlayContentNode?, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
transition.updateAlpha(node: self.soundButtonNode, alpha: 0.0)
}
override func setVisibilityAlpha(_ alpha: CGFloat) {
super.setVisibilityAlpha(alpha)
self.updateSoundButtonVisibility()
}
func updateSoundButtonVisibility() {
if self.soundButtonNode.isSelected {
self.soundButtonNode.alpha = self.visibilityAlpha
} else {
self.soundButtonNode.alpha = 1.0
}
if let validLayout = self.validLayout {
self.updateLayout(size: validLayout.0, metrics: validLayout.1, leftInset: validLayout.2, rightInset: validLayout.3, bottomInset: validLayout.4, transition: .animated(duration: 0.3, curve: .easeInOut))
}
}
@objc func soundButtonPressed() {
self.soundButtonNode.isSelected = !self.soundButtonNode.isSelected
self.updateSoundButtonVisibility()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if !self.soundButtonNode.frame.contains(point) {
return nil
}
return super.hitTest(point, with: event)
}
}
private struct FetchControls {
let fetch: () -> Void
let cancel: () -> Void
@ -161,6 +242,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
private let scrubberView: ChatVideoGalleryItemScrubberView
private let footerContentNode: ChatItemGalleryFooterContentNode
private let overlayContentNode: UniversalVideoGalleryItemOverlayNode
private var videoNode: UniversalVideoNode?
private var videoFramePreview: MediaPlayerFramePreview?
@ -206,6 +288,8 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
self.footerContentNode.performAction = performAction
self.footerContentNode.openActionOptions = openActionOptions
self.overlayContentNode = UniversalVideoGalleryItemOverlayNode()
self.statusButtonNode = HighlightableButtonNode()
self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.5))
self.statusNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 50.0, height: 50.0))
@ -398,6 +482,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
videoNode.backgroundColor = videoNode.ownsContentNode ? UIColor.black : UIColor(rgb: 0x333335)
if item.fromPlayingVideo {
videoNode.canAttachContent = false
self.overlayContentNode.hide()
} else {
self.updateDisplayPlaceholder(!videoNode.ownsContentNode)
}
@ -982,7 +1067,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
transformedSuperFrame = transformedSuperFrame.offsetBy(dx: videoNode.position.x - previousFrame.center.x, dy: videoNode.position.y - previousFrame.center.y)
}
let initialScale: CGFloat = 1.0 //min(videoNode.layer.bounds.width / node.0.view.bounds.width, videoNode.layer.bounds.height / node.0.view.bounds.height)
let initialScale: CGFloat = 1.0
let targetScale = max(transformedFrame.size.width / videoNode.layer.bounds.size.width, transformedFrame.size.height / videoNode.layer.bounds.size.height)
videoNode.backgroundColor = .clear
@ -1197,7 +1282,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
}
}
override func footerContent() -> Signal<GalleryFooterContentNode?, NoError> {
return .single(self.footerContentNode)
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, self.overlayContentNode))
}
}

View file

@ -3,8 +3,36 @@ import UIKit
import Display
import AsyncDisplayKit
private let leftFadeImage = generateImage(CGSize(width: 64.0, height: 1.0), opaque: false, rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
let gradientColors = [UIColor.black.withAlphaComponent(0.5).cgColor, UIColor.black.withAlphaComponent(0.0).cgColor] as CFArray
var locations: [CGFloat] = [0.0, 1.0]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 64.0, y: 0.0), options: CGGradientDrawingOptions())
})
private let rightFadeImage = generateImage(CGSize(width: 64.0, height: 1.0), opaque: false, rotatedContext: { size, context in
let bounds = CGRect(origin: CGPoint(), size: size)
context.clear(bounds)
let gradientColors = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.5).cgColor] as CFArray
var locations: [CGFloat] = [0.0, 1.0]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 64.0, y: 0.0), options: CGGradientDrawingOptions())
})
open class ZoomableContentGalleryItemNode: GalleryItemNode, UIScrollViewDelegate {
public let scrollNode: ASScrollNode
private let leftFadeNode: ASImageNode
private let rightFadeNode: ASImageNode
private var containerLayout: ContainerViewLayout?
@ -32,6 +60,16 @@ open class ZoomableContentGalleryItemNode: GalleryItemNode, UIScrollViewDelegate
self.scrollNode.view.contentInsetAdjustmentBehavior = .never
}
self.leftFadeNode = ASImageNode()
self.leftFadeNode.contentMode = .scaleToFill
self.leftFadeNode.image = leftFadeImage
self.leftFadeNode.isHidden = true
self.rightFadeNode = ASImageNode()
self.rightFadeNode.contentMode = .scaleToFill
self.rightFadeNode.image = rightFadeImage
self.rightFadeNode.isHidden = true
super.init()
self.scrollNode.view.delegate = self
@ -42,41 +80,69 @@ open class ZoomableContentGalleryItemNode: GalleryItemNode, UIScrollViewDelegate
self.scrollNode.view.delaysContentTouches = false
let tapRecognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.contentTap(_:)))
tapRecognizer.tapActionAtPoint = { _ in
tapRecognizer.tapActionAtPoint = { [weak self] location in
if let strongSelf = self {
if location.x < 44.0 || location.x > strongSelf.frame.width - 44.0 {
return .waitForSingleTap
}
}
return .waitForDoubleTap
}
tapRecognizer.highlight = { [weak self] location in
if let strongSelf = self {
if let location = location, location.x < 44.0 {
strongSelf.leftFadeNode.isHidden = false
} else {
strongSelf.leftFadeNode.isHidden = true
}
if let location = location, location.x > strongSelf.frame.width - 44.0 {
strongSelf.rightFadeNode.isHidden = false
} else {
strongSelf.rightFadeNode.isHidden = true
}
}
}
self.scrollNode.view.addGestureRecognizer(tapRecognizer)
self.addSubnode(self.scrollNode)
self.addSubnode(self.leftFadeNode)
self.addSubnode(self.rightFadeNode)
}
@objc open func contentTap(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) {
if recognizer.state == .ended {
if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation {
switch gesture {
case .tap:
self.toggleControlsVisibility()
case .doubleTap:
if let contentView = self.zoomableContent?.1.view, self.scrollNode.view.zoomScale.isLessThanOrEqualTo(self.scrollNode.view.minimumZoomScale) {
let pointInView = self.scrollNode.view.convert(location, to: contentView)
let newZoomScale = self.scrollNode.view.maximumZoomScale
let scrollViewSize = self.scrollNode.view.bounds.size
let w = scrollViewSize.width / newZoomScale
let h = scrollViewSize.height / newZoomScale
let x = pointInView.x - (w / 2.0)
let y = pointInView.y - (h / 2.0)
let rectToZoomTo = CGRect(x: x, y: y, width: w, height: h)
self.scrollNode.view.zoom(to: rectToZoomTo, animated: true)
} else {
self.scrollNode.view.setZoomScale(self.scrollNode.view.minimumZoomScale, animated: true)
}
default:
break
if location.x < 44.0 {
self.goToPreviousItem()
} else if location.x > self.frame.width - 44.0 {
self.goToNextItem()
} else {
switch gesture {
case .tap:
self.toggleControlsVisibility()
case .doubleTap:
if let contentView = self.zoomableContent?.1.view, self.scrollNode.view.zoomScale.isLessThanOrEqualTo(self.scrollNode.view.minimumZoomScale) {
let pointInView = self.scrollNode.view.convert(location, to: contentView)
let newZoomScale = self.scrollNode.view.maximumZoomScale
let scrollViewSize = self.scrollNode.view.bounds.size
let w = scrollViewSize.width / newZoomScale
let h = scrollViewSize.height / newZoomScale
let x = pointInView.x - (w / 2.0)
let y = pointInView.y - (h / 2.0)
let rectToZoomTo = CGRect(x: x, y: y, width: w, height: h)
self.scrollNode.view.zoom(to: rectToZoomTo, animated: true)
} else {
self.scrollNode.view.setZoomScale(self.scrollNode.view.minimumZoomScale, animated: true)
}
default:
break
}
}
}
}
@ -93,6 +159,9 @@ open class ZoomableContentGalleryItemNode: GalleryItemNode, UIScrollViewDelegate
}
self.containerLayout = layout
self.leftFadeNode.frame = CGRect(x: 0.0, y: 0.0, width: layout.size.width * 0.2, height: layout.size.height)
self.rightFadeNode.frame = CGRect(x: layout.size.width - layout.size.width * 0.2, y: 0.0, width: layout.size.width * 0.2, height: layout.size.height)
if shouldResetContents {
var previousFrame: CGRect?
var previousScale: CGFloat?

View file

@ -301,7 +301,7 @@ final class InstantImageGalleryItemNode: ZoomableContentGalleryItemNode {
return self._title.get()
}
override func footerContent() -> Signal<GalleryFooterContentNode?, NoError> {
return .single(self.footerContentNode)
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil))
}
}

View file

@ -170,7 +170,7 @@ public class InstantPageGalleryController: ViewController, StandalonePresentable
private let centralItemTitleView = Promise<UIView?>()
private let centralItemRightBarButtonItem = Promise<UIBarButtonItem?>()
private let centralItemNavigationStyle = Promise<GalleryItemNodeNavigationStyle>()
private let centralItemFooterContentNode = Promise<GalleryFooterContentNode?>()
private let centralItemFooterContentNode = Promise<(GalleryFooterContentNode?, GalleryOverlayContentNode?)>()
private let centralItemAttributesDisposable = DisposableSet();
private let _hiddenMedia = Promise<InstantPageGalleryEntry?>(nil)
@ -243,7 +243,7 @@ public class InstantPageGalleryController: ViewController, StandalonePresentable
self?.navigationItem.rightBarButtonItem = rightBarButtonItem
}))
self.centralItemAttributesDisposable.add(self.centralItemFooterContentNode.get().start(next: { [weak self] footerContentNode in
self.centralItemAttributesDisposable.add(self.centralItemFooterContentNode.get().start(next: { [weak self] footerContentNode, _ in
self?.galleryNode.updatePresentationState({
$0.withUpdatedFooterContentNode(footerContentNode)
}, transition: .immediate)

View file

@ -12,6 +12,11 @@ public enum ItemListPeerActionItemHeight {
case peerList
}
public enum ItemListPeerActionItemColor {
case accent
case destructive
}
public class ItemListPeerActionItem: ListViewItem, ItemListItem {
let presentationData: ItemListPresentationData
let icon: UIImage?
@ -19,16 +24,18 @@ public class ItemListPeerActionItem: ListViewItem, ItemListItem {
public let alwaysPlain: Bool
let editing: Bool
let height: ItemListPeerActionItemHeight
let color: ItemListPeerActionItemColor
public let sectionId: ItemListSectionId
let action: (() -> Void)?
public init(presentationData: ItemListPresentationData, icon: UIImage?, title: String, alwaysPlain: Bool = false, sectionId: ItemListSectionId, height: ItemListPeerActionItemHeight = .peerList, editing: Bool, action: (() -> Void)?) {
public init(presentationData: ItemListPresentationData, icon: UIImage?, title: String, alwaysPlain: Bool = false, sectionId: ItemListSectionId, height: ItemListPeerActionItemHeight = .peerList, color: ItemListPeerActionItemColor = .accent, editing: Bool, action: (() -> Void)?) {
self.presentationData = presentationData
self.icon = icon
self.title = title
self.alwaysPlain = alwaysPlain
self.editing = editing
self.height = height
self.color = color
self.sectionId = sectionId
self.action = action
}
@ -167,7 +174,15 @@ class ItemListPeerActionItemNode: ListViewItemNode {
let editingOffset: CGFloat = (item.editing ? 38.0 : 0.0)
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.title, font: titleFont, textColor: item.presentationData.theme.list.itemAccentColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - editingOffset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let textColor: UIColor
switch item.color {
case .accent:
textColor = item.presentationData.theme.list.itemAccentColor
case .destructive:
textColor = item.presentationData.theme.list.itemDestructiveColor
}
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.title, font: titleFont, textColor: textColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - editingOffset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let separatorHeight = UIScreenPixel

View file

@ -31,6 +31,7 @@ public enum ItemListStickerPackItemControl: Equatable {
case none
case installation(installed: Bool)
case selection
case check(checked: Bool)
}
public final class ItemListStickerPackItem: ListViewItem, ItemListItem {
@ -305,6 +306,8 @@ class ItemListStickerPackItemNode: ItemListRevealOptionsItemNode {
case .selection:
rightInset += 16.0
checkImage = PresentationResourcesItemList.checkIconImage(item.presentationData.theme)
case .check:
rightInset += 16.0
}
var unreadImage: UIImage?
@ -531,6 +534,10 @@ class ItemListStickerPackItemNode: ItemListRevealOptionsItemNode {
strongSelf.selectionIconNode.image = image
strongSelf.selectionIconNode.frame = CGRect(origin: CGPoint(x: params.width - params.rightInset - image.size.width - floor((44.0 - image.size.width) / 2.0), y: floor((contentSize.height - image.size.height) / 2.0)), size: image.size)
}
case let .check(checked):
strongSelf.installationActionNode.isHidden = true
strongSelf.installationActionImageNode.isHidden = true
strongSelf.selectionIconNode.isHidden = true
}
if strongSelf.backgroundNode.supernode == nil {

View file

@ -68,7 +68,7 @@ class SecureIdDocumentGalleryController: ViewController, StandalonePresentableCo
private let centralItemTitle = Promise<String>()
private let centralItemTitleView = Promise<UIView?>()
private let centralItemNavigationStyle = Promise<GalleryItemNodeNavigationStyle>()
private let centralItemFooterContentNode = Promise<GalleryFooterContentNode?>()
private let centralItemFooterContentNode = Promise<(GalleryFooterContentNode?, GalleryOverlayContentNode?)>()
private let centralItemAttributesDisposable = DisposableSet();
private let _hiddenMedia = Promise<SecureIdDocumentGalleryEntry?>(nil)
@ -123,7 +123,7 @@ class SecureIdDocumentGalleryController: ViewController, StandalonePresentableCo
self?.navigationItem.titleView = titleView
}))
self.centralItemAttributesDisposable.add(self.centralItemFooterContentNode.get().start(next: { [weak self] footerContentNode in
self.centralItemAttributesDisposable.add(self.centralItemFooterContentNode.get().start(next: { [weak self] footerContentNode, _ in
self?.galleryNode.updatePresentationState({
$0.withUpdatedFooterContentNode(footerContentNode)
}, transition: .immediate)

View file

@ -210,8 +210,8 @@ final class SecureIdDocumentGalleryItemNode: ZoomableContentGalleryItemNode {
return self._title.get()
}
override func footerContent() -> Signal<GalleryFooterContentNode?, NoError> {
return .single(self.footerContentNode)
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil))
}
}

View file

@ -149,7 +149,7 @@ public class AvatarGalleryController: ViewController, StandalonePresentableContr
private let centralItemTitle = Promise<String>()
private let centralItemTitleView = Promise<UIView?>()
private let centralItemNavigationStyle = Promise<GalleryItemNodeNavigationStyle>()
private let centralItemFooterContentNode = Promise<GalleryFooterContentNode?>()
private let centralItemFooterContentNode = Promise<(GalleryFooterContentNode?, GalleryOverlayContentNode?)>()
private let centralItemAttributesDisposable = DisposableSet();
private let _hiddenMedia = Promise<AvatarGalleryEntry?>(nil)
@ -263,7 +263,7 @@ public class AvatarGalleryController: ViewController, StandalonePresentableContr
self?.navigationItem.titleView = titleView
}))
self.centralItemAttributesDisposable.add(self.centralItemFooterContentNode.get().start(next: { [weak self] footerContentNode in
self.centralItemAttributesDisposable.add(self.centralItemFooterContentNode.get().start(next: { [weak self] footerContentNode, _ in
self?.galleryNode.updatePresentationState({
$0.withUpdatedFooterContentNode(footerContentNode)
}, transition: .immediate)

View file

@ -356,7 +356,7 @@ final class PeerAvatarImageGalleryItemNode: ZoomableContentGalleryItemNode {
}
}
override func footerContent() -> Signal<GalleryFooterContentNode?, NoError> {
return .single(self.footerContentNode)
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
return .single((self.footerContentNode, nil))
}
}

View file

@ -64,6 +64,7 @@ static_library(
"//submodules/TelegramIntents:TelegramIntents",
"//submodules/SolidRoundedButtonNode:SolidRoundedButtonNode",
"//submodules/ChatListSearchItemHeader:ChatListSearchItemHeader",
"//submodules/StatisticsUI:StatisticsUI",
],
frameworks = [
"$SDKROOT/System/Library/Frameworks/Foundation.framework",

View file

@ -26,6 +26,9 @@ static_library(
"//submodules/PeersNearbyIconNode:PeersNearbyIconNode",
"//submodules/Geocoding:Geocoding",
"//submodules/AppBundle:AppBundle",
"//submodules/AnimatedStickerNode:AnimatedStickerNode",
"//submodules/TelegramStringFormatting:TelegramStringFormatting",
"//submodules/TelegramNotices:TelegramNotices",
],
frameworks = [
"$SDKROOT/System/Library/Frameworks/Foundation.framework",

Some files were not shown because too many files have changed in this diff Show more