Various improvements

This commit is contained in:
Ilya Laktyushin 2020-02-11 10:34:00 +04:00
parent fd867a92c7
commit f2ca7b00ef
178 changed files with 16610 additions and 4271 deletions

View file

@ -3,7 +3,7 @@
@implementation Serialization
- (NSUInteger)currentLayer {
return 109;
return 110;
}
- (id _Nullable)parseMessage:(NSData * _Nullable)data {

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

@ -5284,3 +5284,22 @@ Any member of this group will be able to see messages in the channel.";
"Map.PlacesInThisArea" = "Places In This Area";
"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
@ -178,8 +174,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

@ -715,7 +715,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 {
@ -804,8 +804,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
@ -938,6 +948,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

@ -118,7 +118,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)
@ -232,7 +232,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

@ -355,7 +355,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",

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