Legend.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. //
  2. // Legend.swift
  3. // Charts
  4. //
  5. // Copyright 2015 Daniel Cohen Gindi & Philipp Jahoda
  6. // A port of MPAndroidChart for iOS
  7. // Licensed under Apache License 2.0
  8. //
  9. // https://github.com/danielgindi/Charts
  10. //
  11. import Foundation
  12. import CoreGraphics
  13. #if canImport(UIKit)
  14. import UIKit
  15. #endif
  16. #if canImport(AppKit)
  17. import AppKit
  18. #endif
  19. @objc(ChartLegend)
  20. open class Legend: ComponentBase
  21. {
  22. @objc(ChartLegendForm)
  23. public enum Form: Int
  24. {
  25. /// Avoid drawing a form
  26. case none
  27. /// Do not draw the a form, but leave space for it
  28. case empty
  29. /// Use default (default dataset's form to the legend's form)
  30. case `default`
  31. /// Draw a square
  32. case square
  33. /// Draw a circle
  34. case circle
  35. /// Draw a horizontal line
  36. case line
  37. }
  38. @objc(ChartLegendHorizontalAlignment)
  39. public enum HorizontalAlignment: Int
  40. {
  41. case left
  42. case center
  43. case right
  44. }
  45. @objc(ChartLegendVerticalAlignment)
  46. public enum VerticalAlignment: Int
  47. {
  48. case top
  49. case center
  50. case bottom
  51. }
  52. @objc(ChartLegendOrientation)
  53. public enum Orientation: Int
  54. {
  55. case horizontal
  56. case vertical
  57. }
  58. @objc(ChartLegendDirection)
  59. public enum Direction: Int
  60. {
  61. case leftToRight
  62. case rightToLeft
  63. }
  64. /// The legend entries array
  65. @objc open var entries = [LegendEntry]()
  66. /// Entries that will be appended to the end of the auto calculated entries after calculating the legend.
  67. /// (if the legend has already been calculated, you will need to call notifyDataSetChanged() to let the changes take effect)
  68. @objc open var extraEntries = [LegendEntry]()
  69. /// Are the legend labels/colors a custom value or auto calculated? If false, then it's auto, if true, then custom.
  70. ///
  71. /// **default**: false (automatic legend)
  72. private var _isLegendCustom = false
  73. /// The horizontal alignment of the legend
  74. @objc open var horizontalAlignment: HorizontalAlignment = HorizontalAlignment.left
  75. /// The vertical alignment of the legend
  76. @objc open var verticalAlignment: VerticalAlignment = VerticalAlignment.bottom
  77. /// The orientation of the legend
  78. @objc open var orientation: Orientation = Orientation.horizontal
  79. /// Flag indicating whether the legend will draw inside the chart or outside
  80. @objc open var drawInside: Bool = false
  81. /// Flag indicating whether the legend will draw inside the chart or outside
  82. @objc open var isDrawInsideEnabled: Bool { return drawInside }
  83. /// The text direction of the legend
  84. @objc open var direction: Direction = Direction.leftToRight
  85. @objc open var font: NSUIFont = NSUIFont.systemFont(ofSize: 10.0)
  86. @objc open var textColor = NSUIColor.labelOrBlack
  87. /// The form/shape of the legend forms
  88. @objc open var form = Form.square
  89. /// The size of the legend forms
  90. @objc open var formSize = CGFloat(8.0)
  91. /// The line width for forms that consist of lines
  92. @objc open var formLineWidth = CGFloat(3.0)
  93. /// Line dash configuration for shapes that consist of lines.
  94. ///
  95. /// This is how much (in pixels) into the dash pattern are we starting from.
  96. @objc open var formLineDashPhase: CGFloat = 0.0
  97. /// Line dash configuration for shapes that consist of lines.
  98. ///
  99. /// This is the actual dash pattern.
  100. /// I.e. [2, 3] will paint [-- -- ]
  101. /// [1, 3, 4, 2] will paint [- ---- - ---- ]
  102. @objc open var formLineDashLengths: [CGFloat]?
  103. @objc open var xEntrySpace = CGFloat(6.0)
  104. @objc open var yEntrySpace = CGFloat(0.0)
  105. @objc open var formToTextSpace = CGFloat(5.0)
  106. @objc open var stackSpace = CGFloat(3.0)
  107. @objc open var calculatedLabelSizes = [CGSize]()
  108. @objc open var calculatedLabelBreakPoints = [Bool]()
  109. @objc open var calculatedLineSizes = [CGSize]()
  110. public override init()
  111. {
  112. super.init()
  113. self.xOffset = 5.0
  114. self.yOffset = 3.0
  115. }
  116. @objc public init(entries: [LegendEntry])
  117. {
  118. super.init()
  119. self.entries = entries
  120. }
  121. @objc open func getMaximumEntrySize(withFont font: NSUIFont) -> CGSize
  122. {
  123. var maxW = CGFloat(0.0)
  124. var maxH = CGFloat(0.0)
  125. var maxFormSize: CGFloat = 0.0
  126. for entry in entries
  127. {
  128. let formSize = entry.formSize.isNaN ? self.formSize : entry.formSize
  129. if formSize > maxFormSize
  130. {
  131. maxFormSize = formSize
  132. }
  133. guard let label = entry.label
  134. else { continue }
  135. let size = (label as NSString).size(withAttributes: [.font: font])
  136. if size.width > maxW
  137. {
  138. maxW = size.width
  139. }
  140. if size.height > maxH
  141. {
  142. maxH = size.height
  143. }
  144. }
  145. return CGSize(
  146. width: maxW + maxFormSize + formToTextSpace,
  147. height: maxH
  148. )
  149. }
  150. @objc open var neededWidth = CGFloat(0.0)
  151. @objc open var neededHeight = CGFloat(0.0)
  152. @objc open var textWidthMax = CGFloat(0.0)
  153. @objc open var textHeightMax = CGFloat(0.0)
  154. /// flag that indicates if word wrapping is enabled
  155. /// this is currently supported only for `orientation == Horizontal`.
  156. /// you may want to set maxSizePercent when word wrapping, to set the point where the text wraps.
  157. ///
  158. /// **default**: true
  159. @objc open var wordWrapEnabled = true
  160. /// if this is set, then word wrapping the legend is enabled.
  161. @objc open var isWordWrapEnabled: Bool { return wordWrapEnabled }
  162. /// The maximum relative size out of the whole chart view in percent.
  163. /// If the legend is to the right/left of the chart, then this affects the width of the legend.
  164. /// If the legend is to the top/bottom of the chart, then this affects the height of the legend.
  165. ///
  166. /// **default**: 0.95 (95%)
  167. @objc open var maxSizePercent: CGFloat = 0.95
  168. @objc open func calculateDimensions(labelFont: NSUIFont, viewPortHandler: ViewPortHandler)
  169. {
  170. let maxEntrySize = getMaximumEntrySize(withFont: labelFont)
  171. let defaultFormSize = self.formSize
  172. let stackSpace = self.stackSpace
  173. let formToTextSpace = self.formToTextSpace
  174. let xEntrySpace = self.xEntrySpace
  175. let yEntrySpace = self.yEntrySpace
  176. let wordWrapEnabled = self.wordWrapEnabled
  177. let entries = self.entries
  178. let entryCount = entries.count
  179. textWidthMax = maxEntrySize.width
  180. textHeightMax = maxEntrySize.height
  181. switch orientation
  182. {
  183. case .vertical:
  184. var maxWidth = CGFloat(0.0)
  185. var width = CGFloat(0.0)
  186. var maxHeight = CGFloat(0.0)
  187. let labelLineHeight = labelFont.lineHeight
  188. var wasStacked = false
  189. for i in entries.indices
  190. {
  191. let e = entries[i]
  192. let drawingForm = e.form != .none
  193. let formSize = e.formSize.isNaN ? defaultFormSize : e.formSize
  194. if !wasStacked
  195. {
  196. width = 0.0
  197. }
  198. if drawingForm
  199. {
  200. if wasStacked
  201. {
  202. width += stackSpace
  203. }
  204. width += formSize
  205. }
  206. if let label = e.label
  207. {
  208. let size = (label as NSString).size(withAttributes: [.font: labelFont])
  209. if drawingForm && !wasStacked
  210. {
  211. width += formToTextSpace
  212. }
  213. else if wasStacked
  214. {
  215. maxWidth = max(maxWidth, width)
  216. maxHeight += labelLineHeight + yEntrySpace
  217. width = 0.0
  218. wasStacked = false
  219. }
  220. width += size.width
  221. maxHeight += labelLineHeight + yEntrySpace
  222. }
  223. else
  224. {
  225. wasStacked = true
  226. width += formSize
  227. if i < entryCount - 1
  228. {
  229. width += stackSpace
  230. }
  231. }
  232. maxWidth = max(maxWidth, width)
  233. }
  234. neededWidth = maxWidth
  235. neededHeight = maxHeight
  236. case .horizontal:
  237. let labelLineHeight = labelFont.lineHeight
  238. let contentWidth: CGFloat = viewPortHandler.contentWidth * maxSizePercent
  239. // Prepare arrays for calculated layout
  240. if calculatedLabelSizes.count != entryCount
  241. {
  242. calculatedLabelSizes = [CGSize](repeating: CGSize(), count: entryCount)
  243. }
  244. if calculatedLabelBreakPoints.count != entryCount
  245. {
  246. calculatedLabelBreakPoints = [Bool](repeating: false, count: entryCount)
  247. }
  248. calculatedLineSizes.removeAll(keepingCapacity: true)
  249. // Start calculating layout
  250. var maxLineWidth: CGFloat = 0.0
  251. var currentLineWidth: CGFloat = 0.0
  252. var requiredWidth: CGFloat = 0.0
  253. var stackedStartIndex: Int = -1
  254. for i in entries.indices
  255. {
  256. let e = entries[i]
  257. let drawingForm = e.form != .none
  258. let label = e.label
  259. calculatedLabelBreakPoints[i] = false
  260. if stackedStartIndex == -1
  261. {
  262. // we are not stacking, so required width is for this label only
  263. requiredWidth = 0.0
  264. }
  265. else
  266. {
  267. // add the spacing appropriate for stacked labels/forms
  268. requiredWidth += stackSpace
  269. }
  270. // grouped forms have null labels
  271. if let label = label
  272. {
  273. calculatedLabelSizes[i] = (label as NSString).size(withAttributes: [.font: labelFont])
  274. requiredWidth += drawingForm ? formToTextSpace + formSize : 0.0
  275. requiredWidth += calculatedLabelSizes[i].width
  276. }
  277. else
  278. {
  279. calculatedLabelSizes[i] = CGSize()
  280. requiredWidth += drawingForm ? formSize : 0.0
  281. if stackedStartIndex == -1
  282. {
  283. // mark this index as we might want to break here later
  284. stackedStartIndex = i
  285. }
  286. }
  287. if label != nil || i == entryCount - 1
  288. {
  289. let requiredSpacing = currentLineWidth == 0.0 ? 0.0 : xEntrySpace
  290. if (!wordWrapEnabled || // No word wrapping, it must fit.
  291. currentLineWidth == 0.0 || // The line is empty, it must fit.
  292. (contentWidth - currentLineWidth >= requiredSpacing + requiredWidth)) // It simply fits
  293. {
  294. // Expand current line
  295. currentLineWidth += requiredSpacing + requiredWidth
  296. }
  297. else
  298. { // It doesn't fit, we need to wrap a line
  299. // Add current line size to array
  300. calculatedLineSizes.append(CGSize(width: currentLineWidth, height: labelLineHeight))
  301. maxLineWidth = max(maxLineWidth, currentLineWidth)
  302. // Start a new line
  303. calculatedLabelBreakPoints[stackedStartIndex > -1 ? stackedStartIndex : i] = true
  304. currentLineWidth = requiredWidth
  305. }
  306. if i == entryCount - 1
  307. { // Add last line size to array
  308. calculatedLineSizes.append(CGSize(width: currentLineWidth, height: labelLineHeight))
  309. maxLineWidth = max(maxLineWidth, currentLineWidth)
  310. }
  311. }
  312. stackedStartIndex = label != nil ? -1 : stackedStartIndex
  313. }
  314. neededWidth = maxLineWidth
  315. neededHeight = labelLineHeight * CGFloat(calculatedLineSizes.count) +
  316. yEntrySpace * CGFloat(calculatedLineSizes.isEmpty ? 0 : (calculatedLineSizes.count - 1))
  317. }
  318. neededWidth += xOffset
  319. neededHeight += yOffset
  320. }
  321. /// MARK: - Custom legend
  322. /// Sets a custom legend's entries array.
  323. /// * A nil label will start a group.
  324. /// This will disable the feature that automatically calculates the legend entries from the datasets.
  325. /// Call `resetCustom(...)` to re-enable automatic calculation (and then `notifyDataSetChanged()` is needed).
  326. @objc open func setCustom(entries: [LegendEntry])
  327. {
  328. self.entries = entries
  329. _isLegendCustom = true
  330. }
  331. /// Calling this will disable the custom legend entries (set by `setLegend(...)`). Instead, the entries will again be calculated automatically (after `notifyDataSetChanged()` is called).
  332. @objc open func resetCustom()
  333. {
  334. _isLegendCustom = false
  335. }
  336. /// **default**: false (automatic legend)
  337. /// `true` if a custom legend entries has been set
  338. @objc open var isLegendCustom: Bool
  339. {
  340. return _isLegendCustom
  341. }
  342. }