Improve Number Formatting (#1266)
* Improves number formatting (IOS-246) * Implement formatting > 1T (IOS-246) * Fix typo (IOS-246) * Update MastodonTests/MetricFormatterTests.swift Co-authored-by: Nathan Mattes <hallo@bullenscheisse.de> * Improve decimal formatting and add tests (IOS-246) --------- Co-authored-by: Nathan Mattes <hallo@bullenscheisse.de>
This commit is contained in:
parent
5925436bc5
commit
cc9faf5aea
@ -50,6 +50,7 @@
|
|||||||
2A86A14629892944007F1062 /* MultiFollowersCountIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14529892944007F1062 /* MultiFollowersCountIntentHandler.swift */; };
|
2A86A14629892944007F1062 /* MultiFollowersCountIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14529892944007F1062 /* MultiFollowersCountIntentHandler.swift */; };
|
||||||
2A86A14929892B3A007F1062 /* MultiFollowersCountWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14829892B3A007F1062 /* MultiFollowersCountWidget.swift */; };
|
2A86A14929892B3A007F1062 /* MultiFollowersCountWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14829892B3A007F1062 /* MultiFollowersCountWidget.swift */; };
|
||||||
2A86A14B2989326E007F1062 /* MultiFollowersCountWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14A2989326E007F1062 /* MultiFollowersCountWidgetView.swift */; };
|
2A86A14B2989326E007F1062 /* MultiFollowersCountWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14A2989326E007F1062 /* MultiFollowersCountWidgetView.swift */; };
|
||||||
|
2A8DCC612BBEA6DE00B2A4EC /* MetricFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8DCC602BBEA6DE00B2A4EC /* MetricFormatterTests.swift */; };
|
||||||
2A90A157296EEE500026C155 /* MastodonSDKDynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 2A90A156296EEE500026C155 /* MastodonSDKDynamic */; };
|
2A90A157296EEE500026C155 /* MastodonSDKDynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 2A90A156296EEE500026C155 /* MastodonSDKDynamic */; };
|
||||||
2A9D0664298C048800BF38CB /* LatestFollowersWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */; };
|
2A9D0664298C048800BF38CB /* LatestFollowersWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */; };
|
||||||
2A9D0666298C05A800BF38CB /* LatestFollowersWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */; };
|
2A9D0666298C05A800BF38CB /* LatestFollowersWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */; };
|
||||||
@ -652,6 +653,7 @@
|
|||||||
2A86A14529892944007F1062 /* MultiFollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountIntentHandler.swift; sourceTree = "<group>"; };
|
2A86A14529892944007F1062 /* MultiFollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountIntentHandler.swift; sourceTree = "<group>"; };
|
||||||
2A86A14829892B3A007F1062 /* MultiFollowersCountWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountWidget.swift; sourceTree = "<group>"; };
|
2A86A14829892B3A007F1062 /* MultiFollowersCountWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountWidget.swift; sourceTree = "<group>"; };
|
||||||
2A86A14A2989326E007F1062 /* MultiFollowersCountWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountWidgetView.swift; sourceTree = "<group>"; };
|
2A86A14A2989326E007F1062 /* MultiFollowersCountWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountWidgetView.swift; sourceTree = "<group>"; };
|
||||||
|
2A8DCC602BBEA6DE00B2A4EC /* MetricFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricFormatterTests.swift; sourceTree = "<group>"; };
|
||||||
2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestFollowersWidget.swift; sourceTree = "<group>"; };
|
2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestFollowersWidget.swift; sourceTree = "<group>"; };
|
||||||
2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestFollowersWidgetView.swift; sourceTree = "<group>"; };
|
2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestFollowersWidgetView.swift; sourceTree = "<group>"; };
|
||||||
2AAAA34D2B04DE21004C6672 /* VisionKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VisionKit.framework; path = System/Library/Frameworks/VisionKit.framework; sourceTree = SDKROOT; };
|
2AAAA34D2B04DE21004C6672 /* VisionKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VisionKit.framework; path = System/Library/Frameworks/VisionKit.framework; sourceTree = SDKROOT; };
|
||||||
@ -2115,6 +2117,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DB427DEC25BAA00100D1B89D /* MastodonTests.swift */,
|
DB427DEC25BAA00100D1B89D /* MastodonTests.swift */,
|
||||||
|
2A8DCC602BBEA6DE00B2A4EC /* MetricFormatterTests.swift */,
|
||||||
DB427DEE25BAA00100D1B89D /* Info.plist */,
|
DB427DEE25BAA00100D1B89D /* Info.plist */,
|
||||||
);
|
);
|
||||||
path = MastodonTests;
|
path = MastodonTests;
|
||||||
@ -3848,6 +3851,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
2A8DCC612BBEA6DE00B2A4EC /* MetricFormatterTests.swift in Sources */,
|
||||||
DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */,
|
DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
@ -8,6 +8,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"enabled" : false,
|
||||||
"id" : "06DB4A21-6DDF-454F-ABEE-E77DD7AAA146",
|
"id" : "06DB4A21-6DDF-454F-ABEE-E77DD7AAA146",
|
||||||
"name" : "Arabic",
|
"name" : "Arabic",
|
||||||
"options" : {
|
"options" : {
|
||||||
@ -15,6 +16,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"enabled" : false,
|
||||||
"id" : "D95412BD-821C-4FE5-9DA6-8D77C0972B72",
|
"id" : "D95412BD-821C-4FE5-9DA6-8D77C0972B72",
|
||||||
"name" : "Catalan",
|
"name" : "Catalan",
|
||||||
"options" : {
|
"options" : {
|
||||||
@ -22,6 +24,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"enabled" : false,
|
||||||
"id" : "EE9219EA-2011-48B5-A475-309C99F91D6D",
|
"id" : "EE9219EA-2011-48B5-A475-309C99F91D6D",
|
||||||
"name" : "Chinese, Simplified",
|
"name" : "Chinese, Simplified",
|
||||||
"options" : {
|
"options" : {
|
||||||
@ -29,6 +32,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"enabled" : false,
|
||||||
"id" : "6FBBAF31-D445-482E-B67B-271F8216AEB6",
|
"id" : "6FBBAF31-D445-482E-B67B-271F8216AEB6",
|
||||||
"name" : "Dutch",
|
"name" : "Dutch",
|
||||||
"options" : {
|
"options" : {
|
||||||
@ -36,6 +40,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"enabled" : false,
|
||||||
"id" : "45A93B8D-54C5-4906-8AC6-5B6FE561CA25",
|
"id" : "45A93B8D-54C5-4906-8AC6-5B6FE561CA25",
|
||||||
"name" : "French",
|
"name" : "French",
|
||||||
"options" : {
|
"options" : {
|
||||||
@ -43,6 +48,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"enabled" : false,
|
||||||
"id" : "8344111A-3025-4CA0-838C-AF94EBA4D4BE",
|
"id" : "8344111A-3025-4CA0-838C-AF94EBA4D4BE",
|
||||||
"name" : "German",
|
"name" : "German",
|
||||||
"options" : {
|
"options" : {
|
||||||
@ -50,6 +56,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"enabled" : false,
|
||||||
"id" : "4EE64E47-F9E5-4189-8571-20D29941F854",
|
"id" : "4EE64E47-F9E5-4189-8571-20D29941F854",
|
||||||
"name" : "Japanese",
|
"name" : "Japanese",
|
||||||
"options" : {
|
"options" : {
|
||||||
@ -57,6 +64,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"enabled" : false,
|
||||||
"id" : "746F1EBA-E12B-40C4-85C6-A14DC61A180B",
|
"id" : "746F1EBA-E12B-40C4-85C6-A14DC61A180B",
|
||||||
"name" : "Spanish",
|
"name" : "Spanish",
|
||||||
"options" : {
|
"options" : {
|
||||||
@ -64,6 +72,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"enabled" : false,
|
||||||
"id" : "EDA29FF5-1F0E-451A-863D-0899CE07CB09",
|
"id" : "EDA29FF5-1F0E-451A-863D-0899CE07CB09",
|
||||||
"name" : "Spanish (Latin America)",
|
"name" : "Spanish (Latin America)",
|
||||||
"options" : {
|
"options" : {
|
||||||
@ -76,7 +85,11 @@
|
|||||||
},
|
},
|
||||||
"testTargets" : [
|
"testTargets" : [
|
||||||
{
|
{
|
||||||
"enabled" : false,
|
"skippedTests" : [
|
||||||
|
"MastodonTests",
|
||||||
|
"MastodonTests\/testConnectOnion()",
|
||||||
|
"MastodonTests\/testWebFinger()"
|
||||||
|
],
|
||||||
"target" : {
|
"target" : {
|
||||||
"containerPath" : "container:Mastodon.xcodeproj",
|
"containerPath" : "container:Mastodon.xcodeproj",
|
||||||
"identifier" : "DB427DE725BAA00100D1B89D",
|
"identifier" : "DB427DE725BAA00100D1B89D",
|
||||||
|
@ -7,8 +7,30 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
enum DecimalUnit: Int {
|
||||||
|
case one = 1
|
||||||
|
case ten = 10
|
||||||
|
case hundred = 100
|
||||||
|
case thousand = 1_000
|
||||||
|
case million = 1_000_000
|
||||||
|
case billion = 1_000_000_000
|
||||||
|
case trillion = 1_000_000_000_000
|
||||||
|
|
||||||
|
var asInt: Int {
|
||||||
|
self.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
var asDouble: Double {
|
||||||
|
Double(self.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public final class MastodonMetricFormatter: Formatter {
|
public final class MastodonMetricFormatter: Formatter {
|
||||||
|
|
||||||
|
private let ten_thousands = DecimalUnit.thousand.asInt * 10
|
||||||
|
private let ten_millions = DecimalUnit.million.asInt * 10
|
||||||
|
|
||||||
public func string(from number: Int) -> String? {
|
public func string(from number: Int) -> String? {
|
||||||
let isPositive = number >= 0
|
let isPositive = number >= 0
|
||||||
let symbol = isPositive ? "" : "-"
|
let symbol = isPositive ? "" : "-"
|
||||||
@ -19,20 +41,30 @@ public final class MastodonMetricFormatter: Formatter {
|
|||||||
let metric: String
|
let metric: String
|
||||||
|
|
||||||
switch value {
|
switch value {
|
||||||
case 0..<1000: // 0 ~ 1K
|
case 0 ..< DecimalUnit.thousand.asInt: // 0 ~ 1K
|
||||||
metric = String(value)
|
|
||||||
case 1000..<10000: // 1K ~ 10K
|
|
||||||
numberFormatter.maximumFractionDigits = 1
|
|
||||||
let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000)
|
|
||||||
metric = string + "K"
|
|
||||||
case 10000..<1000000: // 10K ~ 1M
|
|
||||||
numberFormatter.maximumFractionDigits = 0
|
numberFormatter.maximumFractionDigits = 0
|
||||||
let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000)
|
let string = numberFormatter.string(from: NSNumber(value: value)) ?? String(value)
|
||||||
|
metric = string
|
||||||
|
case DecimalUnit.thousand.asInt ..< DecimalUnit.million.asInt: // 1K ~ 1M
|
||||||
|
numberFormatter.maximumFractionDigits = value < ten_thousands ? 1 : 0
|
||||||
|
let string = numberFormatter.string(from: NSNumber(value: Double(value) / DecimalUnit.thousand.asDouble)) ??
|
||||||
|
String(value / DecimalUnit.thousand.asInt)
|
||||||
metric = string + "K"
|
metric = string + "K"
|
||||||
default:
|
case DecimalUnit.million.asInt ..< DecimalUnit.billion.asInt: // 1M ~ 1B
|
||||||
numberFormatter.maximumFractionDigits = 0
|
numberFormatter.maximumFractionDigits = value < ten_millions ? 1 : 0
|
||||||
let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000000.0)) ?? String(value / 1000000)
|
let string = numberFormatter.string(from: NSNumber(value: Double(value) / DecimalUnit.million.asDouble)) ??
|
||||||
|
String(value / DecimalUnit.million.asInt)
|
||||||
metric = string + "M"
|
metric = string + "M"
|
||||||
|
case DecimalUnit.billion.asInt ..< DecimalUnit.trillion.asInt: // 1B ~ 1T
|
||||||
|
numberFormatter.maximumFractionDigits = 0
|
||||||
|
let string = numberFormatter.string(from: NSNumber(value: Double(value) / DecimalUnit.billion.asDouble)) ??
|
||||||
|
String(value / DecimalUnit.billion.asInt)
|
||||||
|
metric = string + "B"
|
||||||
|
default: // > 1T
|
||||||
|
numberFormatter.maximumFractionDigits = 0
|
||||||
|
let string = numberFormatter.string(from: NSNumber(value: Double(value) / DecimalUnit.trillion.asDouble)) ??
|
||||||
|
String(value / DecimalUnit.trillion.asInt)
|
||||||
|
metric = string + "T"
|
||||||
}
|
}
|
||||||
|
|
||||||
return symbol + metric
|
return symbol + metric
|
||||||
|
126
MastodonTests/MetricFormatterTests.swift
Normal file
126
MastodonTests/MetricFormatterTests.swift
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import MastodonUI
|
||||||
|
|
||||||
|
class MetricFormatterTests: XCTestCase {
|
||||||
|
|
||||||
|
func test_tensFormat() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 12)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "12")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_hundredsFormat() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 123)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "123")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_thousandOneFormat() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 1001)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "1K")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_thousandFiftyFormat() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 1050)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "1K")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_thousandNinetynineFormat() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 1099)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "1,1K")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_thousandNinehundredFormat() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 1900)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "1,9K")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_thousandsFormat() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 1234)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "1,2K")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_sixThousandsFormat() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 6666)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "6,7K")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_millionsFormat_oneTwoThreeMillion() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 1_234_567)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "1,2M")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_millionsFormat_exactlyTenMillion() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 10_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "10M")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_millionsFormat_twelveOneTwoThreeMillion() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 12_345_789)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "12M")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_billionsFormat() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 10_000_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "10B")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_billionsFormat_oneTwoThreeBillion() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 12_345_678_912)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "12B")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_trillionsFormat() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 10_000_000_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "10T")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_trillionsFormat_oneTwoThreeTrillion() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 12_345_678_912_345)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "12T")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_trillionsFormat_oneTwoThree_youGottaBeKiddinMeTrillion() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 12_345_678_912_345_678)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "12346T")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_trillionsFormat_oneTwoThree_lastDigitBeforeIntegerOverflowTrillion() {
|
||||||
|
let formatter = MastodonMetricFormatter()
|
||||||
|
let value = formatter.string(from: 12_345_678_912_345_678_91)
|
||||||
|
|
||||||
|
XCTAssertEqual(value, "1234568T")
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user