├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .swiftlint.yml ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── Percentage.xcscheme ├── Package.swift ├── Sources └── Percentage │ └── Percentage.swift ├── Tests └── PercentageTests │ └── PercentageTests.swift ├── license └── readme.md /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | runs-on: macos-latest 8 | steps: 9 | - uses: actions/checkout@v5 10 | - run: swift test --parallel 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v5 15 | - uses: norio-nomura/action-swiftlint@3.2.1 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.build 2 | /Packages 3 | /*.xcodeproj 4 | xcuserdata 5 | /.swiftpm 6 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | only_rules: 2 | - anyobject_protocol 3 | - array_init 4 | - block_based_kvo 5 | - class_delegate_protocol 6 | - closing_brace 7 | - closure_end_indentation 8 | - closure_parameter_position 9 | - closure_spacing 10 | - collection_alignment 11 | - colon 12 | - comma 13 | - compiler_protocol_init 14 | - computed_accessors_order 15 | - conditional_returns_on_newline 16 | - contains_over_filter_count 17 | - contains_over_filter_is_empty 18 | - contains_over_first_not_nil 19 | - contains_over_range_nil_comparison 20 | - control_statement 21 | - custom_rules 22 | - discarded_notification_center_observer 23 | - discouraged_direct_init 24 | - discouraged_object_literal 25 | - discouraged_optional_boolean 26 | - discouraged_optional_collection 27 | - duplicate_enum_cases 28 | - duplicate_imports 29 | - dynamic_inline 30 | - empty_collection_literal 31 | - empty_count 32 | - empty_enum_arguments 33 | - empty_parameters 34 | - empty_parentheses_with_trailing_closure 35 | - empty_string 36 | - empty_xctest_method 37 | - enum_case_associated_values_count 38 | - explicit_init 39 | - fallthrough 40 | - fatal_error_message 41 | - first_where 42 | - flatmap_over_map_reduce 43 | - for_where 44 | - generic_type_name 45 | - ibinspectable_in_extension 46 | - identical_operands 47 | - identifier_name 48 | - implicit_getter 49 | - implicit_return 50 | - inclusive_language 51 | - inert_defer 52 | - is_disjoint 53 | - joined_default_parameter 54 | - last_where 55 | - leading_whitespace 56 | - legacy_cggeometry_functions 57 | - legacy_constant 58 | - legacy_constructor 59 | - legacy_hashing 60 | - legacy_multiple 61 | - legacy_nsgeometry_functions 62 | - legacy_random 63 | - literal_expression_end_indentation 64 | - lower_acl_than_parent 65 | - mark 66 | - modifier_order 67 | - multiline_arguments 68 | - multiline_function_chains 69 | - multiline_literal_brackets 70 | - multiline_parameters 71 | - multiline_parameters_brackets 72 | - nimble_operator 73 | - no_extension_access_modifier 74 | - no_fallthrough_only 75 | - no_space_in_method_call 76 | - notification_center_detachment 77 | - nsobject_prefer_isequal 78 | - number_separator 79 | - opening_brace 80 | - operator_usage_whitespace 81 | - operator_whitespace 82 | - orphaned_doc_comment 83 | - overridden_super_call 84 | - prefer_self_type_over_type_of_self 85 | - prefer_zero_over_explicit_init 86 | - private_action 87 | - private_outlet 88 | - private_unit_test 89 | - prohibited_super_call 90 | - protocol_property_accessors_order 91 | - reduce_boolean 92 | - reduce_into 93 | - redundant_discardable_let 94 | - redundant_nil_coalescing 95 | - redundant_objc_attribute 96 | - redundant_optional_initialization 97 | - redundant_set_access_control 98 | - redundant_string_enum_value 99 | - redundant_type_annotation 100 | - redundant_void_return 101 | - required_enum_case 102 | - return_arrow_whitespace 103 | - shorthand_operator 104 | - sorted_first_last 105 | - statement_position 106 | - static_operator 107 | - strong_iboutlet 108 | - superfluous_disable_command 109 | - switch_case_alignment 110 | - switch_case_on_newline 111 | - syntactic_sugar 112 | - test_case_accessibility 113 | - toggle_bool 114 | - trailing_closure 115 | - trailing_comma 116 | - trailing_newline 117 | - trailing_semicolon 118 | - trailing_whitespace 119 | - unavailable_function 120 | - unneeded_break_in_switch 121 | - unneeded_parentheses_in_closure_argument 122 | - unowned_variable_capture 123 | - untyped_error_in_catch 124 | - unused_capture_list 125 | - unused_closure_parameter 126 | - unused_control_flow_label 127 | - unused_enumerated 128 | - unused_optional_binding 129 | - unused_setter_value 130 | - valid_ibinspectable 131 | - vertical_parameter_alignment 132 | - vertical_parameter_alignment_on_call 133 | - vertical_whitespace_closing_braces 134 | - vertical_whitespace_opening_braces 135 | - void_return 136 | - weak_delegate 137 | - xct_specific_matcher 138 | - xctfail_message 139 | - yoda_condition 140 | analyzer_rules: 141 | - unused_declaration 142 | - unused_import 143 | number_separator: 144 | minimum_length: 5 145 | identifier_name: 146 | max_length: 147 | warning: 100 148 | error: 100 149 | min_length: 150 | warning: 2 151 | error: 2 152 | validates_start_with_lowercase: false 153 | allowed_symbols: 154 | - '_' 155 | excluded: 156 | - 'x' 157 | - 'y' 158 | - 'a' 159 | - 'b' 160 | - 'x1' 161 | - 'x2' 162 | - 'y1' 163 | - 'y2' 164 | custom_rules: 165 | no_nsrect: 166 | regex: '\bNSRect\b' 167 | match_kinds: typeidentifier 168 | message: 'Use CGRect instead of NSRect' 169 | no_nssize: 170 | regex: '\bNSSize\b' 171 | match_kinds: typeidentifier 172 | message: 'Use CGSize instead of NSSize' 173 | no_nspoint: 174 | regex: '\bNSPoint\b' 175 | match_kinds: typeidentifier 176 | message: 'Use CGPoint instead of NSPoint' 177 | swiftui_state_private: 178 | regex: '@(State|StateObject)\s+var' 179 | message: "SwiftUI @State/@StateObject properties should be private" 180 | final_class: 181 | regex: '^class [a-zA-Z\d]+[^{]+\{' 182 | message: "Classes should be marked as final whenever possible. If you actually need it to be subclassable, just add `// swiftlint:disable:next final_class`." 183 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/Percentage.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 47 | 53 | 54 | 55 | 56 | 57 | 67 | 68 | 74 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Percentage", 6 | products: [ 7 | .library( 8 | name: "Percentage", 9 | targets: [ 10 | "Percentage" 11 | ] 12 | ) 13 | ], 14 | targets: [ 15 | .target( 16 | name: "Percentage" 17 | ), 18 | .testTarget( 19 | name: "PercentageTests", 20 | dependencies: [ 21 | "Percentage" 22 | ] 23 | ) 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /Sources/Percentage/Percentage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | ``` 5 | import Percentage 6 | 7 | 10% + 5.5% 8 | //=> 15.5% 9 | 10 | -10% / 2 11 | //=> -5% 12 | 13 | (40% + 93%) * 3 14 | //=> 399% 15 | 16 | 50% * 50% 17 | //=> 25% 18 | 19 | 30% > 25% 20 | //=> true 21 | 22 | 50%.of(200) 23 | //=> 100 24 | 25 | Percentage(50) 26 | //=> 50% 27 | 28 | Percentage(fraction: 0.5) 29 | //=> 50% 30 | 31 | Percentage.from(100, of: 200) 32 | //=> 50% 33 | 34 | Percentage.change(from: 100, to: 150) 35 | //=> 50% 36 | 37 | 50%.fraction 38 | //=> 0.5 39 | 40 | 10%.rawValue 41 | //=> 10 42 | 43 | 50%.isWithinStandardRange 44 | //=> true 45 | 46 | 150%.clamped(to: 0%...100%) 47 | //=> 100% 48 | 49 | 110%.clampedZeroToHundred 50 | //=> 100% 51 | 52 | 100.increased(by: 20%) 53 | //=> 120 54 | 55 | 100.decreased(by: 20%) 56 | //=> 80 57 | 58 | 40%.originalValueBeforeIncrease(finalValue: 120) 59 | //=> 85.71428571428571 60 | 61 | 12%.originalValueBeforeDecrease(finalValue: 106) 62 | //=> 120.45454545454545 63 | 64 | 90%.isPercentOf(67) 65 | //=> 74.44444444444444 66 | 67 | 33.333%.formatted(decimalPlaces: 1) 68 | //=> "33.3%" 69 | 70 | // With locale (macOS 12.0+/iOS 15.0+) 71 | 50%.formatted(decimalPlaces: 1, locale: Locale(languageCode: .french)) 72 | //=> "50,0 %" 73 | 74 | print("\(1%)") 75 | //=> "1%" 76 | 77 | Percent.random(in: 10%...20%) 78 | //=> "14.3%" 79 | ``` 80 | */ 81 | public struct Percentage: Hashable, Codable, Sendable { 82 | /** 83 | The raw percentage number. 84 | 85 | ``` 86 | 10%.rawValue 87 | //=> 10 88 | ``` 89 | */ 90 | public let rawValue: Double 91 | 92 | /** 93 | Get the percentage as a fraction. 94 | 95 | ``` 96 | 50%.fraction 97 | //=> 0.5 98 | ``` 99 | */ 100 | public var fraction: Double { rawValue / 100 } 101 | 102 | /** 103 | Clamp the percentage to a value between 0% and 100%. 104 | 105 | ``` 106 | 110%.clampedZeroToHundred 107 | //=> 100% 108 | 109 | -1%.clampedZeroToHundred 110 | //=> 0% 111 | 112 | 60%.clampedZeroToHundred 113 | //=> 60% 114 | ``` 115 | */ 116 | public var clampedZeroToHundred: Self { 117 | if rawValue > 100 { 118 | 100% 119 | } else if rawValue < 0 { 120 | 0% 121 | } else { 122 | self 123 | } 124 | } 125 | 126 | /** 127 | Check if the percentage is within the standard 0-100% range. 128 | 129 | ``` 130 | 50%.isWithinStandardRange 131 | //=> true 132 | 133 | 150%.isWithinStandardRange 134 | //=> false 135 | 136 | (-10%).isWithinStandardRange 137 | //=> false 138 | ``` 139 | */ 140 | public var isWithinStandardRange: Bool { 141 | rawValue >= 0 && rawValue <= 100 142 | } 143 | 144 | /** 145 | Create a `Percentage` from a `BinaryFloatingPoint`, for example, `Double` or `CGFloat`. 146 | 147 | ``` 148 | let cgFloat: CGFloat = 50.5 149 | Percentage(cgFloat) 150 | //=> 50.5% 151 | ``` 152 | */ 153 | public init(_ percentage: some BinaryFloatingPoint) { 154 | self.init(rawValue: Double(percentage)) 155 | } 156 | 157 | /** 158 | Create a `Percentage` from a `BinaryInteger`, for example, `Int`. 159 | 160 | ``` 161 | let int = 50 162 | Percentage(int) 163 | //=> 50% 164 | ``` 165 | */ 166 | public init(_ percentage: some BinaryInteger) { 167 | self.init(rawValue: Double(percentage)) 168 | } 169 | 170 | /** 171 | Create a `Percentage` from a fraction. 172 | 173 | ``` 174 | Percentage(fraction: 0.5) 175 | //=> "50%" 176 | ``` 177 | */ 178 | public init(fraction: Double) { 179 | self.init(rawValue: fraction * 100) 180 | } 181 | 182 | /** 183 | Returns how much the percentage of the given integer value is. 184 | 185 | ``` 186 | 50%.of(200) 187 | //=> 100 188 | ``` 189 | */ 190 | public func of(_ value: Value) -> Value { 191 | value * Value(rawValue.rounded()) / 100 192 | } 193 | 194 | /** 195 | Returns how much the percentage of the given integer value is exactly, represented as floating-point. 196 | 197 | ``` 198 | 50%.of(201) as Double 199 | //=> 100.5 200 | ``` 201 | */ 202 | public func of( 203 | _ value: some BinaryInteger 204 | ) -> ReturnValue { 205 | ReturnValue(value) * ReturnValue(rawValue) / 100 206 | } 207 | 208 | /** 209 | Returns how much the percentage of the given floating-point value is. 210 | 211 | ``` 212 | 50%.of(250.5) 213 | //=> 125.25 214 | ``` 215 | */ 216 | public func of(_ value: Value) -> Value { 217 | value * Value(rawValue) / 100 218 | } 219 | } 220 | 221 | extension Percentage { 222 | /** 223 | Returns a random value within the given range. 224 | 225 | ``` 226 | Percent.random(in: 10%...20%) 227 | //=> Can be 10%, 11%, 12%, 19.98%, etc. 228 | ``` 229 | */ 230 | public static func random(in range: ClosedRange) -> Self { 231 | self.init(fraction: .random(in: range.lowerBound.fraction...range.upperBound.fraction)) 232 | } 233 | 234 | /** 235 | Create a `Percentage` from a value and a total. 236 | 237 | ``` 238 | Percentage.from(100, of: 200) 239 | //=> 50% 240 | 241 | Percentage.from(75, of: 300) 242 | //=> 25% 243 | ``` 244 | */ 245 | public static func from( 246 | _ value: some BinaryInteger, 247 | of total: some BinaryInteger 248 | ) -> Self { 249 | guard total != 0 else { 250 | return self.init(0) 251 | } 252 | 253 | return self.init(Double(value) / Double(total) * 100) 254 | } 255 | 256 | /** 257 | Create a `Percentage` from a value and a total. 258 | 259 | ``` 260 | Percentage.from(50.5, of: 101) 261 | //=> 50% 262 | 263 | Percentage.from(12.5, of: 50.0) 264 | //=> 25% 265 | ``` 266 | */ 267 | public static func from( 268 | _ value: some BinaryFloatingPoint, 269 | of total: some BinaryFloatingPoint 270 | ) -> Self { 271 | guard total != 0 else { 272 | return self.init(0) 273 | } 274 | 275 | return self.init(Double(value) / Double(total) * 100) 276 | } 277 | 278 | /** 279 | Find the original value before a percentage increase. 280 | 281 | For example, if a value is 120 after a 40% increase, this returns the original value (85.714...). 282 | 283 | ``` 284 | 40%.originalValueBeforeIncrease(finalValue: 120) 285 | //=> 85.714... 286 | 287 | 50%.originalValueBeforeIncrease(finalValue: 150) 288 | //=> 100 289 | ``` 290 | */ 291 | public func originalValueBeforeIncrease( 292 | finalValue: some BinaryFloatingPoint 293 | ) -> Double { 294 | Double(finalValue) / (1 + fraction) 295 | } 296 | 297 | /** 298 | Find the original value before a percentage increase. 299 | 300 | For example, if a value is 120 after a 40% increase, this returns the original value (85.714...). 301 | 302 | ``` 303 | 40%.originalValueBeforeIncrease(finalValue: 120) 304 | //=> 85.714... 305 | 306 | 50%.originalValueBeforeIncrease(finalValue: 150) 307 | //=> 100 308 | ``` 309 | */ 310 | public func originalValueBeforeIncrease( 311 | finalValue: some BinaryInteger 312 | ) -> Double { 313 | Double(finalValue) / (1 + fraction) 314 | } 315 | 316 | /** 317 | Find the original value before a percentage decrease. 318 | 319 | For example, if a value is 106 after a 12% decrease, this returns the original value (120.454...). 320 | 321 | ``` 322 | 12%.originalValueBeforeDecrease(finalValue: 106) 323 | //=> 120.454... 324 | 325 | 20%.originalValueBeforeDecrease(finalValue: 80) 326 | //=> 100 327 | ``` 328 | */ 329 | public func originalValueBeforeDecrease( 330 | finalValue: some BinaryFloatingPoint 331 | ) -> Double { 332 | guard fraction < 1 else { 333 | // Cannot have a decrease of 100% or more 334 | return .infinity 335 | } 336 | 337 | return Double(finalValue) / (1 - fraction) 338 | } 339 | 340 | /** 341 | Find the original value before a percentage decrease. 342 | 343 | For example, if a value is 106 after a 12% decrease, this returns the original value (120.454...). 344 | 345 | ``` 346 | 12%.originalValueBeforeDecrease(finalValue: 106) 347 | //=> 120.454... 348 | 349 | 20%.originalValueBeforeDecrease(finalValue: 80) 350 | //=> 100 351 | ``` 352 | */ 353 | public func originalValueBeforeDecrease( 354 | finalValue: some BinaryInteger 355 | ) -> Double { 356 | guard fraction < 1 else { 357 | // Cannot have a decrease of 100% or more 358 | return .infinity 359 | } 360 | 361 | return Double(finalValue) / (1 - fraction) 362 | } 363 | 364 | /** 365 | Find what value this percentage is of. 366 | 367 | For example, "67 is 90% of what?" returns 74.444... 368 | 369 | ``` 370 | 90%.isPercentOf(67) 371 | //=> 74.444... 372 | 373 | 50%.isPercentOf(50) 374 | //=> 100 375 | ``` 376 | */ 377 | public func isPercentOf(_ value: some BinaryFloatingPoint) -> Double { 378 | guard fraction != 0 else { 379 | return .infinity 380 | } 381 | 382 | return Double(value) / fraction 383 | } 384 | 385 | /** 386 | Find what value this percentage is of. 387 | 388 | For example, "67 is 90% of what?" returns 74.444... 389 | 390 | ``` 391 | 90%.isPercentOf(67) 392 | //=> 74.444... 393 | 394 | 50%.isPercentOf(50) 395 | //=> 100 396 | ``` 397 | */ 398 | public func isPercentOf(_ value: some BinaryInteger) -> Double { 399 | guard fraction != 0 else { 400 | return .infinity 401 | } 402 | 403 | return Double(value) / fraction 404 | } 405 | 406 | /** 407 | Clamp the percentage to a specific range. 408 | 409 | ``` 410 | 150%.clamped(to: 0%...100%) 411 | //=> 100% 412 | 413 | (-50%).clamped(to: 0%...100%) 414 | //=> 0% 415 | 416 | 50%.clamped(to: 25%...75%) 417 | //=> 50% 418 | ``` 419 | */ 420 | public func clamped(to range: ClosedRange) -> Self { 421 | if self < range.lowerBound { 422 | return range.lowerBound 423 | } else if self > range.upperBound { 424 | return range.upperBound 425 | } else { 426 | return self 427 | } 428 | } 429 | 430 | /** 431 | Calculate the percentage change between two values. 432 | 433 | ``` 434 | Percentage.change(from: 100, to: 150) 435 | //=> 50% 436 | 437 | Percentage.change(from: 150, to: 100) 438 | //=> -33.33% 439 | 440 | Percentage.change(from: 100, to: 200) 441 | //=> 100% 442 | ``` 443 | */ 444 | public static func change( 445 | from originalValue: T, 446 | to newValue: T 447 | ) -> Self { 448 | guard originalValue != 0 else { 449 | if newValue == 0 { 450 | return self.init(0) 451 | } 452 | 453 | return self.init(Double.infinity) 454 | } 455 | 456 | let change = Double(Int(newValue) - Int(originalValue)) / Double(originalValue) * 100 457 | return self.init(change) 458 | } 459 | 460 | /** 461 | Calculate the percentage change between two values. 462 | 463 | ``` 464 | Percentage.change(from: 100.0, to: 150.0) 465 | //=> 50% 466 | 467 | Percentage.change(from: 50.5, to: 75.75) 468 | //=> 50% 469 | ``` 470 | */ 471 | public static func change( 472 | from originalValue: some BinaryFloatingPoint, 473 | to newValue: some BinaryFloatingPoint 474 | ) -> Self { 475 | guard originalValue != 0 else { 476 | if newValue == 0 { 477 | return self.init(0) 478 | } 479 | return self.init(Double.infinity) 480 | } 481 | 482 | let change = (Double(newValue) - Double(originalValue)) / Double(originalValue) * 100 483 | return self.init(change) 484 | } 485 | 486 | /** 487 | Format the percentage with a specific number of decimal places. 488 | 489 | ``` 490 | 33.333%.formatted(decimalPlaces: 1) 491 | //=> "33.3%" 492 | 493 | 33.333%.formatted(decimalPlaces: 0) 494 | //=> "33%" 495 | 496 | 50%.formatted(decimalPlaces: 2) 497 | //=> "50.00%" 498 | 499 | // With specific locale (macOS 12.0+/iOS 15.0+) 500 | 50.5%.formatted(decimalPlaces: 1, locale: Locale(languageCode: .french)) 501 | //=> "50,5 %" (French formatting) 502 | ``` 503 | */ 504 | public func formatted(decimalPlaces: Int, locale: Locale = .current) -> String { 505 | if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) { 506 | return fraction.formatted( 507 | .percent 508 | .precision(.fractionLength(decimalPlaces)) 509 | .locale(locale) 510 | ) 511 | } else { 512 | // Fallback for older OS versions 513 | let formatter = NumberFormatter() 514 | formatter.numberStyle = .percent 515 | formatter.minimumFractionDigits = decimalPlaces 516 | formatter.maximumFractionDigits = decimalPlaces 517 | formatter.locale = locale 518 | return formatter.string(for: fraction) ?? "\(String(format: "%g", rawValue))%" 519 | } 520 | } 521 | } 522 | 523 | @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) 524 | extension Percentage { 525 | /** 526 | Format the percentage using a FormatStyle. 527 | 528 | ``` 529 | 33.333%.formatted(.percent.precision(.fractionLength(1))) 530 | //=> "33.3%" 531 | 532 | 50%.formatted(.percent.precision(.fractionLength(0))) 533 | //=> "50%" 534 | ``` 535 | */ 536 | public func formatted( 537 | _ style: F 538 | ) -> F.FormatOutput where F.FormatInput == Double { 539 | style.format(fraction) 540 | } 541 | } 542 | 543 | extension BinaryInteger { 544 | /** 545 | Increase the value by a percentage. 546 | 547 | ``` 548 | 100.increased(by: 20%) 549 | //=> 120 550 | 551 | 50.increased(by: 100%) 552 | //=> 100 553 | ``` 554 | */ 555 | public func increased(by percentage: Percentage) -> Self { 556 | self + percentage.of(self) 557 | } 558 | 559 | /** 560 | Decrease the value by a percentage. 561 | 562 | ``` 563 | 100.decreased(by: 20%) 564 | //=> 80 565 | 566 | 50.decreased(by: 50%) 567 | //=> 25 568 | ``` 569 | */ 570 | public func decreased(by percentage: Percentage) -> Self { 571 | self - percentage.of(self) 572 | } 573 | } 574 | 575 | extension BinaryFloatingPoint { 576 | /** 577 | Increase the value by a percentage. 578 | 579 | ``` 580 | 100.0.increased(by: 20%) 581 | //=> 120.0 582 | 583 | 50.5.increased(by: 100%) 584 | //=> 101.0 585 | ``` 586 | */ 587 | public func increased(by percentage: Percentage) -> Self { 588 | self + percentage.of(self) 589 | } 590 | 591 | /** 592 | Decrease the value by a percentage. 593 | 594 | ``` 595 | 100.0.decreased(by: 20%) 596 | //=> 80.0 597 | 598 | 50.5.decreased(by: 50%) 599 | //=> 25.25 600 | ``` 601 | */ 602 | public func decreased(by percentage: Percentage) -> Self { 603 | self - percentage.of(self) 604 | } 605 | } 606 | 607 | extension Percentage: RawRepresentable { 608 | public init(rawValue: Double) { 609 | self.rawValue = rawValue 610 | } 611 | } 612 | 613 | extension Percentage: Comparable { 614 | public static func < (lhs: Self, rhs: Self) -> Bool { 615 | lhs.rawValue < rhs.rawValue 616 | } 617 | } 618 | 619 | extension Percentage: CustomStringConvertible { 620 | public var description: String { 621 | formatted(decimalPlaces: 2) 622 | } 623 | } 624 | 625 | // swiftlint:disable static_operator 626 | prefix operator - 627 | 628 | public prefix func - (percentage: Percentage) -> Percentage { 629 | Percentage(-percentage.rawValue) 630 | } 631 | 632 | postfix operator % 633 | 634 | public postfix func % (value: Double) -> Percentage { 635 | Percentage(value) 636 | } 637 | 638 | public postfix func % (value: Int) -> Percentage { 639 | Percentage(Double(value)) 640 | } 641 | // swiftlint:enable static_operator 642 | 643 | extension Percentage: ExpressibleByFloatLiteral { 644 | public init(floatLiteral value: Double) { 645 | self.init(rawValue: value) 646 | } 647 | } 648 | 649 | extension Percentage: ExpressibleByIntegerLiteral { 650 | public init(integerLiteral value: Double) { 651 | self.init(rawValue: value) 652 | } 653 | } 654 | 655 | extension Percentage: Numeric { 656 | public typealias Magnitude = Double.Magnitude 657 | 658 | public static var zero: Self { 0 } 659 | 660 | public static func + (lhs: Self, rhs: Self) -> Self { 661 | self.init(lhs.rawValue + rhs.rawValue) 662 | } 663 | 664 | public static func += (lhs: inout Self, rhs: Self) { 665 | lhs = lhs + rhs 666 | } 667 | 668 | public static func - (lhs: Self, rhs: Self) -> Self { 669 | self.init(lhs.rawValue - rhs.rawValue) 670 | } 671 | 672 | public static func -= (lhs: inout Self, rhs: Self) { 673 | lhs = lhs - rhs 674 | } 675 | 676 | public static func * (lhs: Self, rhs: Self) -> Self { 677 | self.init(fraction: lhs.fraction * rhs.fraction) 678 | } 679 | 680 | public static func *= (lhs: inout Self, rhs: Self) { 681 | lhs = lhs * rhs 682 | } 683 | 684 | public var magnitude: Magnitude { rawValue.magnitude } 685 | 686 | public init?(exactly source: some BinaryInteger) { 687 | guard let value = Double(exactly: source) else { 688 | return nil 689 | } 690 | 691 | self.init(value) 692 | } 693 | } 694 | 695 | extension Percentage { 696 | public static func / (lhs: Self, rhs: Self) -> Self { 697 | self.init(fraction: lhs.fraction / rhs.fraction) 698 | } 699 | 700 | public static func /= (lhs: inout Self, rhs: Self) { 701 | lhs = lhs / rhs 702 | } 703 | } 704 | -------------------------------------------------------------------------------- /Tests/PercentageTests/PercentageTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | import Foundation 3 | import CoreGraphics 4 | @testable import Percentage 5 | 6 | struct PercentageTests { 7 | @Test 8 | func percentage() { 9 | #expect(10% == 10%) // swiftlint:disable:this identical_operands 10 | #expect(1.1%.rawValue == 1.1) 11 | #expect(10% + 5.5% == 15.5%) 12 | #expect(50% * 50% == 25%) 13 | #expect(50% / 50% == 100%) 14 | #expect(50%.of(200) == 100) 15 | #expect(Percentage(50.5) == 50.5%) 16 | #expect(Percentage(rawValue: 50.5) == 50.5%) 17 | 18 | let int = 50 19 | #expect(Percentage(int) == 50%) 20 | 21 | let int8: Int8 = 50 22 | #expect(Percentage(int8) == 50%) 23 | 24 | let cgFloat: CGFloat = 50.5 25 | #expect(Percentage(cgFloat) == 50.5%) 26 | 27 | let float: Float = 50.5 28 | #expect(Percentage(float) == 50.5%) 29 | 30 | #expect(Percentage(fraction: 0.5) == 50%) 31 | #expect(50%.fraction == 0.5) 32 | 33 | #expect(30% > 25%) 34 | } 35 | 36 | @Test 37 | func clampedZeroToHundred() { 38 | #expect(101%.clampedZeroToHundred == 100%) 39 | #expect((-1%).clampedZeroToHundred == 0%) 40 | #expect(40%.clampedZeroToHundred == 40%) 41 | } 42 | 43 | @Test 44 | func percentageOf() { 45 | #expect(50%.of(200) == 100) 46 | #expect(50%.of(201) == 100) 47 | #expect(50%.of(201) as Double == 100.5) 48 | #expect(50%.of(250.5) == 125.25) 49 | #expect(25%.of(Float64(200)) == Float64(50)) 50 | } 51 | 52 | @Test 53 | func arithmetics() { 54 | #expect(1% + 1% == 2%) 55 | #expect(1% - 1% == 0%) 56 | #expect(20% / 10% == 200%) 57 | } 58 | 59 | @Test 60 | func arithmeticsDouble() { 61 | #expect(1% + 1 == 2%) 62 | #expect(1% - 1 == 0%) 63 | } 64 | 65 | @Test 66 | func arithmeticsMutating() { 67 | var plus = 1% 68 | plus += 1% 69 | #expect(plus == 2%) 70 | 71 | var minus = 1% 72 | minus -= 1% 73 | #expect(minus == 0%) 74 | 75 | var divide = 20% 76 | divide /= 10% 77 | #expect(divide == 200%) 78 | } 79 | 80 | @Test 81 | func arithmeticsMutatingDouble() { 82 | var plus = 1% 83 | plus += 1 84 | #expect(plus == 2%) 85 | 86 | var minus = 1% 87 | minus -= 1 88 | #expect(minus == 0%) 89 | 90 | var divide = 20% 91 | divide /= 10 92 | #expect(divide == 200%) 93 | } 94 | 95 | @Test 96 | func codable() { 97 | struct Foo: Codable { 98 | let alpha: Percentage 99 | } 100 | 101 | let foo = Foo(alpha: 1%) 102 | let data = try! JSONEncoder().encode(foo) 103 | let string = String(data: data, encoding: .utf8)! 104 | 105 | #expect(string == "{\"alpha\":1}") 106 | } 107 | 108 | @Test 109 | func random() { 110 | let range = 10%...20% 111 | let random = Percentage.random(in: range) 112 | #expect(range.contains(random)) 113 | } 114 | 115 | @Test 116 | func percentageFrom() { 117 | // Test: Percent.from(100, of: 200) //=> 50% 118 | #expect(Percentage.from(100, of: 200) == 50%) 119 | #expect(Percentage.from(50, of: 100) == 50%) 120 | #expect(Percentage.from(25, of: 50) == 50%) 121 | #expect(Percentage.from(75, of: 300) == 25%) 122 | #expect(Percentage.from(0, of: 100) == 0%) 123 | #expect(Percentage.from(100, of: 100) == 100%) 124 | #expect(Percentage.from(150, of: 100) == 150%) 125 | 126 | // Test with floating point values 127 | #expect(Percentage.from(50.5, of: 101) == 50%) 128 | #expect(Percentage.from(12.5, of: 50.0) == 25%) 129 | } 130 | 131 | @Test 132 | func originalValueBeforeIncrease() { 133 | // If a value is 120 after a 40% increase, what is the original value? 134 | // Formula: original = final / (1 + percentage) 135 | // 120 / 1.4 = 85.71428... 136 | #expect(abs(40%.originalValueBeforeIncrease(finalValue: 120) - 85.71428571428571) < 0.00001) 137 | #expect(abs(50%.originalValueBeforeIncrease(finalValue: 150) - 100.0) < 0.00001) 138 | #expect(abs(100%.originalValueBeforeIncrease(finalValue: 200) - 100.0) < 0.00001) 139 | #expect(abs(0%.originalValueBeforeIncrease(finalValue: 100) - 100.0) < 0.00001) 140 | #expect(abs(25%.originalValueBeforeIncrease(finalValue: 125) - 100.0) < 0.00001) 141 | } 142 | 143 | @Test 144 | func originalValueBeforeDecrease() { 145 | // If a value is 106 after a 12% decrease, what is the original value? 146 | // Formula: original = final / (1 - percentage) 147 | // 106 / 0.88 = 120.45454... 148 | #expect(abs(12%.originalValueBeforeDecrease(finalValue: 106) - 120.45454545454545) < 0.00001) 149 | #expect(abs(50%.originalValueBeforeDecrease(finalValue: 50) - 100.0) < 0.00001) 150 | #expect(abs(20%.originalValueBeforeDecrease(finalValue: 80) - 100.0) < 0.00001) 151 | #expect(abs(0%.originalValueBeforeDecrease(finalValue: 100) - 100.0) < 0.00001) 152 | #expect(abs(25%.originalValueBeforeDecrease(finalValue: 75) - 100.0) < 0.00001) 153 | } 154 | 155 | @Test 156 | func isPercentOf() { 157 | // "x IS y% OF what?" - 67 is 90% of what? 158 | // Formula: result = value / percentage 159 | // 67 / 0.9 = 74.44444... 160 | #expect(abs(90%.isPercentOf(67) - 74.44444444444444) < 0.00001) 161 | #expect(abs(50%.isPercentOf(50) - 100.0) < 0.00001) 162 | #expect(abs(100%.isPercentOf(100) - 100.0) < 0.00001) 163 | #expect(abs(25%.isPercentOf(25) - 100.0) < 0.00001) 164 | #expect(abs(200%.isPercentOf(200) - 100.0) < 0.00001) 165 | } 166 | 167 | @Test 168 | func isWithinStandardRange() { 169 | #expect(0%.isWithinStandardRange) 170 | #expect(50%.isWithinStandardRange) 171 | #expect(100%.isWithinStandardRange) 172 | #expect(!(-1%).isWithinStandardRange) 173 | #expect(!101%.isWithinStandardRange) 174 | #expect(!150%.isWithinStandardRange) 175 | #expect(!(-50%).isWithinStandardRange) 176 | } 177 | 178 | @Test 179 | func clampedToRange() { 180 | #expect(50%.clamped(to: 0%...100%) == 50%) 181 | #expect(150%.clamped(to: 0%...100%) == 100%) 182 | #expect((-50%).clamped(to: 0%...100%) == 0%) 183 | #expect(75%.clamped(to: 0%...100%) == 75%) 184 | 185 | // Custom ranges 186 | #expect(50%.clamped(to: 25%...75%) == 50%) 187 | #expect(20%.clamped(to: 25%...75%) == 25%) 188 | #expect(80%.clamped(to: 25%...75%) == 75%) 189 | #expect((-10%).clamped(to: (-20%)...20%) == -10%) 190 | } 191 | 192 | @Test 193 | func percentageChange() { 194 | // Percentage.change(from: 100, to: 150) //=> 50% 195 | #expect(Percentage.change(from: 100, to: 150) == 50%) 196 | #expect(abs(Percentage.change(from: 150, to: 100).rawValue - (-33.333333333333336)) < 0.00001) 197 | #expect(Percentage.change(from: 100, to: 100) == 0%) 198 | #expect(Percentage.change(from: 50, to: 75) == 50%) 199 | #expect(Percentage.change(from: 200, to: 100) == -50%) 200 | #expect(Percentage.change(from: 100, to: 0) == -100%) 201 | #expect(Percentage.change(from: 100, to: 200) == 100%) 202 | 203 | // Floating point values 204 | #expect(Percentage.change(from: 100.0, to: 150.0) == 50%) 205 | #expect(Percentage.change(from: 50.5, to: 75.75) == 50%) 206 | 207 | // Edge case: from 0 208 | #expect(Percentage.change(from: 0, to: 100).rawValue.isInfinite) 209 | #expect(Percentage.change(from: 0, to: 0) == 0%) 210 | } 211 | 212 | @Test(.disabled("Infinite recursion in Swift Testing framework")) 213 | func 214 | numericExtensionsInteger() { 215 | // Integer types - using explicit Int type to avoid ambiguity with Percentage literals 216 | let hundred: Int = 100 217 | let twentyPercent = Percentage(20) 218 | #expect(hundred.increased(by: twentyPercent) == 120) 219 | #expect(hundred.decreased(by: twentyPercent) == 80) 220 | 221 | let fifty: Int = 50 222 | let oneHundredPercent = Percentage(100) 223 | let fiftyPercent = Percentage(50) 224 | #expect(fifty.increased(by: oneHundredPercent) == 100) 225 | #expect(fifty.decreased(by: fiftyPercent) == 25) 226 | 227 | let twoHundred: Int = 200 228 | let zeroPercent = Percentage(0) 229 | #expect(twoHundred.increased(by: zeroPercent) == 200) 230 | #expect(twoHundred.decreased(by: zeroPercent) == 200) 231 | 232 | // Negative percentages 233 | #expect(hundred.increased(by: Percentage(-20)) == 80) 234 | #expect(hundred.decreased(by: Percentage(-20)) == 120) 235 | 236 | // Large percentages 237 | #expect(hundred.increased(by: Percentage(200)) == 300) 238 | #expect(hundred.decreased(by: Percentage(100)) == 0) 239 | 240 | // Test with Int8, Int16, etc. 241 | let int8: Int8 = 50 242 | #expect(int8.increased(by: Percentage(20)) == 60) 243 | #expect(int8.decreased(by: Percentage(20)) == 40) 244 | 245 | let int16: Int16 = 100 246 | #expect(int16.increased(by: Percentage(50)) == 150) 247 | #expect(int16.decreased(by: Percentage(25)) == 75) 248 | } 249 | 250 | @Test 251 | func numericExtensionsFloatingPoint() { 252 | // Floating point types 253 | #expect(abs(100.0.increased(by: 20%) - 120.0) < 0.00001) 254 | #expect(abs(100.0.decreased(by: 20%) - 80.0) < 0.00001) 255 | #expect(abs(50.5.increased(by: 100%) - 101.0) < 0.00001) 256 | #expect(abs(50.5.decreased(by: 50%) - 25.25) < 0.00001) 257 | 258 | // CGFloat 259 | let cgFloat: CGFloat = 100.0 260 | #expect(abs(cgFloat.increased(by: 25%) - 125.0) < 0.00001) 261 | #expect(abs(cgFloat.decreased(by: 10%) - 90.0) < 0.00001) 262 | 263 | // Float 264 | let float: Float = 200.0 265 | #expect(abs(Float(float.increased(by: 15%)) - 230.0) < 0.00001) 266 | #expect(abs(Float(float.decreased(by: 30%)) - 140.0) < 0.00001) 267 | } 268 | 269 | @Test 270 | func formatting() { 271 | if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) { 272 | // Basic formatting - use US locale for consistent testing 273 | let usLocale = Locale(identifier: "en_US") 274 | #expect(33.333%.formatted(decimalPlaces: 1, locale: usLocale) == "33.3%") 275 | #expect(33.333%.formatted(decimalPlaces: 0, locale: usLocale) == "33%") 276 | #expect(33.333%.formatted(decimalPlaces: 2, locale: usLocale) == "33.33%") 277 | #expect(33.336%.formatted(decimalPlaces: 2, locale: usLocale) == "33.34%") 278 | #expect(50%.formatted(decimalPlaces: 0, locale: usLocale) == "50%") 279 | #expect(50%.formatted(decimalPlaces: 2, locale: usLocale) == "50.00%") 280 | 281 | // Negative percentages 282 | #expect((-33.333%).formatted(decimalPlaces: 1, locale: usLocale) == "-33.3%") 283 | #expect((-50%).formatted(decimalPlaces: 0, locale: usLocale) == "-50%") 284 | 285 | // Large percentages - note: modern formatter uses grouping separator 286 | #expect(150%.formatted(decimalPlaces: 0, locale: usLocale) == "150%") 287 | #expect(1234.567%.formatted(decimalPlaces: 1, locale: usLocale) == "1,234.6%") 288 | 289 | // Edge cases 290 | #expect(0%.formatted(decimalPlaces: 0, locale: usLocale) == "0%") 291 | #expect(0.004%.formatted(decimalPlaces: 2, locale: usLocale) == "0.00%") 292 | #expect(0.005%.formatted(decimalPlaces: 2, locale: usLocale) == "0.00%") // Rounding: 0.005% = 0.00005 as fraction, rounds down 293 | 294 | // Test that it respects locale 295 | let frLocale = Locale(identifier: "fr_FR") 296 | let formattedFr = 50.5%.formatted(decimalPlaces: 1, locale: frLocale) 297 | #expect(formattedFr.contains("50")) // French formatting may differ 298 | 299 | // Test default locale (current) 300 | let defaultFormatted = 50%.formatted(decimalPlaces: 0) 301 | #expect(!defaultFormatted.isEmpty) 302 | } else { 303 | // Test legacy formatting for older OS versions 304 | let usLocale = Locale(identifier: "en_US") 305 | #expect(33.333%.formatted(decimalPlaces: 1, locale: usLocale) == "33.3%") 306 | #expect(50%.formatted(decimalPlaces: 0, locale: usLocale) == "50%") 307 | } 308 | } 309 | 310 | @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) 311 | @Test 312 | func formattedWithFormatStyle() { 313 | // Test with FormatStyle using modern locale API 314 | let usLocale = Locale(identifier: "en_US") 315 | 316 | let percent1 = 33.333% 317 | let formatted1 = percent1.formatted(.percent.precision(.fractionLength(1)).locale(usLocale)) 318 | #expect(formatted1 == "33.3%") 319 | 320 | let percent2 = 50% 321 | let formatted2 = percent2.formatted(.percent.precision(.fractionLength(0)).locale(usLocale)) 322 | #expect(formatted2 == "50%") 323 | 324 | let percent3 = 0.5% 325 | let formatted3 = percent3.formatted(.percent.precision(.fractionLength(2)).locale(usLocale)) 326 | #expect(formatted3 == "0.50%") 327 | 328 | // Test locale-specific formatting 329 | let frLocale = Locale(identifier: "fr_FR") 330 | let percent4 = 50% 331 | let formatted4 = percent4.formatted(.percent.locale(frLocale)) 332 | #expect(formatted4.contains("50")) // French uses different formatting 333 | 334 | // Test using the formatted extension method directly 335 | let percent5 = 75.5% 336 | let formatted5 = percent5.formatted(.percent.precision(.fractionLength(1))) 337 | #expect(!formatted5.isEmpty) 338 | } 339 | 340 | // MARK: - Additional comprehensive tests 341 | 342 | @Test 343 | func originalValueBeforeIncreaseWithIntegers() { 344 | // Test BinaryInteger overload 345 | #expect(abs(40%.originalValueBeforeIncrease(finalValue: 140) - 100.0) < 0.00001) 346 | #expect(abs(25%.originalValueBeforeIncrease(finalValue: 125) - 100.0) < 0.00001) 347 | #expect(abs(100%.originalValueBeforeIncrease(finalValue: 200) - 100.0) < 0.00001) 348 | } 349 | 350 | @Test 351 | func originalValueBeforeDecreaseWithIntegers() { 352 | // Test BinaryInteger overload 353 | #expect(abs(20%.originalValueBeforeDecrease(finalValue: 80) - 100.0) < 0.00001) 354 | #expect(abs(50%.originalValueBeforeDecrease(finalValue: 50) - 100.0) < 0.00001) 355 | #expect(abs(25%.originalValueBeforeDecrease(finalValue: 75) - 100.0) < 0.00001) 356 | } 357 | 358 | @Test 359 | func isPercentOfWithIntegers() { 360 | // Test BinaryInteger overload 361 | #expect(abs(50%.isPercentOf(50) - 100.0) < 0.00001) 362 | #expect(abs(25%.isPercentOf(25) - 100.0) < 0.00001) 363 | #expect(abs(90%.isPercentOf(90) - 100.0) < 0.00001) 364 | } 365 | 366 | @Test 367 | func percentageFromFloatingPoint() { 368 | // Test BinaryFloatingPoint overload 369 | #expect(Percentage.from(50.5, of: 101.0) == 50%) 370 | #expect(Percentage.from(25.25, of: 101.0) == 25%) 371 | #expect(abs(Percentage.from(33.333, of: 99.999).rawValue - 33.333333333333336) < 0.00001) 372 | } 373 | 374 | @Test 375 | func percentageChangeWithIntegers() { 376 | // Test BinaryInteger overload specifically 377 | #expect(Percentage.change(from: 100, to: 150) == 50%) 378 | #expect(Percentage.change(from: 50, to: 75) == 50%) 379 | #expect(Percentage.change(from: 200, to: 100) == -50%) 380 | } 381 | 382 | @Test 383 | func description() { 384 | // Test the description property 385 | let fifty = 50% 386 | let description = fifty.description 387 | #expect(description.contains("50")) 388 | #expect(description.contains("%")) 389 | 390 | let decimal = 33.5% 391 | let decimalDescription = decimal.description 392 | #expect(decimalDescription.contains("33")) 393 | #expect(decimalDescription.contains("%")) 394 | } 395 | 396 | @Test 397 | func comparableProtocol() { 398 | // Test Comparable conformance thoroughly 399 | #expect(10% < 20%) 400 | #expect(50% > 25%) 401 | #expect(100% >= 100%) // swiftlint:disable:this identical_operands 402 | #expect(0% <= 0%) // swiftlint:disable:this identical_operands 403 | #expect(!(50% < 25%)) 404 | #expect(!(25% > 50%)) 405 | 406 | // Test with negative percentages 407 | #expect((-10%) < 10%) 408 | #expect(10% > (-10%)) 409 | #expect((-50%) < (-25%)) 410 | } 411 | 412 | @Test 413 | func arithmeticOperators() { 414 | // Test all arithmetic operators comprehensively 415 | #expect(50% + 25% == 75%) 416 | #expect(75% - 25% == 50%) 417 | #expect(50% * 2% == 1%) // 50% * 2% = 1% (0.5 * 0.02 = 0.01) 418 | #expect(100% / 50% == 200%) // 100% / 50% = 2 (1.0 / 0.5 = 2.0) 419 | 420 | // Test with zero 421 | #expect(50% + 0% == 50%) 422 | #expect(50% - 0% == 50%) 423 | #expect(50% * 0% == 0%) 424 | 425 | // Test negative arithmetic 426 | #expect(50% + (-25%) == 25%) 427 | #expect(50% - (-25%) == 75%) 428 | } 429 | 430 | @Test 431 | func edgeCases() { 432 | // Test edge cases and boundary conditions 433 | 434 | // Very large percentages 435 | let large = Percentage(1_000_000) 436 | #expect(large.rawValue == 1_000_000) 437 | 438 | // Very small percentages 439 | let tiny = Percentage(0.0001) 440 | #expect(tiny.rawValue == 0.0001) 441 | 442 | // Negative percentages 443 | let negative = Percentage(-50) 444 | #expect(negative.rawValue == -50) 445 | #expect(!negative.isWithinStandardRange) 446 | 447 | // Test percentage of zero 448 | #expect(50%.of(0) == 0) 449 | #expect(100%.of(0) == 0) 450 | 451 | // Test zero percentage 452 | let zero = Percentage(0) 453 | #expect(zero.of(100) == 0) 454 | #expect(zero.of(250.5) == 0.0) 455 | } 456 | 457 | @Test 458 | func fractionProperty() { 459 | // Test fraction property specifically 460 | #expect(0%.fraction == 0.0) 461 | #expect(50%.fraction == 0.5) 462 | #expect(100%.fraction == 1.0) 463 | #expect(200%.fraction == 2.0) 464 | #expect((-50%).fraction == -0.5) 465 | 466 | // Test with decimal percentages 467 | #expect(25.5%.fraction == 0.255) 468 | #expect(abs(33.333%.fraction - 0.33333) < 0.00001) 469 | } 470 | 471 | @Test 472 | func protocolConformances() { 473 | // Test Hashable conformance 474 | let p1 = 50% 475 | let p2 = 50% 476 | let p3 = 75% 477 | 478 | #expect(p1.hashValue == p2.hashValue) 479 | #expect(p1.hashValue != p3.hashValue) 480 | 481 | // Test that equal percentages are hashable-equal 482 | let set: Set = [p1, p2, p3] 483 | #expect(set.count == 2) // p1 and p2 should be considered equal 484 | } 485 | 486 | @Test 487 | func literalConformances() { 488 | // Test ExpressibleByIntegerLiteral 489 | let intLiteral: Percentage = 50 490 | #expect(intLiteral.rawValue == 50) 491 | 492 | // Test ExpressibleByFloatLiteral 493 | let floatLiteral: Percentage = 50.5 494 | #expect(floatLiteral.rawValue == 50.5) 495 | 496 | // Test that percentage literals work 497 | let percentLiteral = 50% 498 | #expect(percentLiteral.rawValue == 50) 499 | } 500 | 501 | @Test 502 | func numericProtocolConformance() { 503 | // Test Numeric protocol conformance 504 | #expect(Percentage.zero == 0%) 505 | 506 | var mutable = 50% 507 | mutable += 25% 508 | #expect(mutable == 75%) 509 | 510 | mutable -= 25% 511 | #expect(mutable == 50%) 512 | 513 | mutable *= 2% 514 | #expect(mutable == 1%) // 50% * 2% = 1% (0.5 * 0.02 = 0.01) 515 | 516 | // Test magnitude 517 | #expect(50%.magnitude == 50.0) 518 | #expect((-50%).magnitude == 50.0) 519 | } 520 | } 521 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Percentage 2 | 3 | > A percentage type for Swift 4 | 5 | Makes percentages more readable and type-safe, for example, for APIs that currently accept a fraction `Double`. 6 | 7 | ```diff 8 | -.opacity(0.45) 9 | +.opacity(45%) 10 | ``` 11 | 12 | ## Install 13 | 14 | Add the following to `Package.swift`: 15 | 16 | ```swift 17 | .package(url: "https://github.com/sindresorhus/Percentage", from: "2.0.0") 18 | ``` 19 | 20 | [Or add the package in Xcode.](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app) 21 | 22 | ## Usage 23 | 24 | See the [source](Sources/Percentage/Percentage.swift) for docs. 25 | 26 | ```swift 27 | import Percentage 28 | 29 | 10% + 5.5% 30 | //=> 15.5% 31 | 32 | -10% / 2 33 | //=> -5% 34 | 35 | (40% + 93%) * 3 36 | //=> 399% 37 | 38 | 50% * 50% 39 | //=> 25% 40 | 41 | 30% > 25% 42 | //=> true 43 | 44 | 50%.of(200) 45 | //=> 100 46 | 47 | Percentage(50) 48 | //=> 50% 49 | 50 | Percentage(fraction: 0.5) 51 | //=> 50% 52 | 53 | Percentage.from(100, of: 200) 54 | //=> 50% 55 | 56 | Percentage.change(from: 100, to: 150) 57 | //=> 50% 58 | 59 | 50%.fraction 60 | //=> 0.5 61 | 62 | 10%.rawValue 63 | //=> 10 64 | 65 | 50%.isWithinStandardRange 66 | //=> true 67 | 68 | 150%.clamped(to: 0%...100%) 69 | //=> 100% 70 | 71 | 110%.clampedZeroToHundred 72 | //=> 100% 73 | 74 | 100.increased(by: 20%) 75 | //=> 120 76 | 77 | 100.decreased(by: 20%) 78 | //=> 80 79 | 80 | 40%.originalValueBeforeIncrease(finalValue: 120) 81 | //=> 85.71428571428571 82 | 83 | 12%.originalValueBeforeDecrease(finalValue: 106) 84 | //=> 120.45454545454545 85 | 86 | 90%.isPercentOf(67) 87 | //=> 74.44444444444444 88 | 89 | 33.333%.formatted(decimalPlaces: 1) 90 | //=> "33.3%" 91 | 92 | // With locale (macOS 12.0+/iOS 15.0+) 93 | 50%.formatted(decimalPlaces: 1, locale: Locale(languageCode: .french)) 94 | //=> "50,0 %" 95 | 96 | print("\(1%)") 97 | //=> "1%" 98 | 99 | Percent.random(in: 10%...20%) 100 | //=> "14.3%" 101 | ``` 102 | 103 | The type conforms to `Hashable`, `Codable`, `RawRepresentable`, `Comparable`, `ExpressibleByFloatLiteral`, `ExpressibleByIntegerLiteral`, `Numeric`, `Sendable`, and supports all the arithmetic operators. 104 | 105 | ### Codable 106 | 107 | The percentage value is encoded as a single value: 108 | 109 | ```swift 110 | struct Foo: Codable { 111 | let alpha: Percentage 112 | } 113 | 114 | let foo = Foo(alpha: 1%) 115 | let data = try! JSONEncoder().encode(foo) 116 | let string = String(data: data, encoding: .utf8)! 117 | 118 | print(string) 119 | //=> "{\"alpha\":1}" 120 | ``` 121 | 122 | ## FAQ 123 | 124 | #### Can you support Carthage and CocoaPods? 125 | 126 | No, but you can still use Swift Package Manager for this package even though you mainly use Carthage or CocoaPods. 127 | 128 | ## Related 129 | 130 | - [Defaults](https://github.com/sindresorhus/Defaults) - Swifty and modern UserDefaults 131 | - [KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts) - Add user-customizable global keyboard shortcuts to your macOS app 132 | - [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin) - Add "Launch at Login" functionality to your macOS app 133 | - [More…](https://github.com/search?q=user%3Asindresorhus+language%3Aswift+archived%3Afalse&type=repositories) 134 | --------------------------------------------------------------------------------