From 2caf35d7f5589937db40378eb009d05e668c3047 Mon Sep 17 00:00:00 2001 From: mike-dydx <149746839+mike-dydx@users.noreply.github.com> Date: Wed, 24 Jul 2024 20:29:36 -0400 Subject: [PATCH] add slider to target leverage screen (#213) * create slider text input view * add target leverage slider * temp * fix custom amount switch behavior and fix overwriting bug * undo changes to platforminput * fix lines to 1 for leverage buttons * temp * fix precision * add precision to slider * clean up * address PR comments --------- Co-authored-by: Mike --- .../Utilities/_Extensions/Double+String.swift | 31 ---- .../dydxFormatter.xcodeproj/project.pbxproj | 4 + .../dydxNumberInputFormatter.swift | 45 ++++++ .../dydxTakeProfitStopLossViewPresenter.swift | 86 +++++++---- .../dydxTargetLeverageViewBuilder.swift | 30 ++-- .../dydxViews.xcodeproj/project.pbxproj | 25 ++- .../dydxComponents/dydxSliderInputView.swift | 70 +++++++++ .../dydxComponents/dydxSliderView.swift | 93 ++++++++++++ .../dydxTitledNumberField.swift | 143 ++++++++++++++++++ .../dydxCustomAmountViewModel.swift | 137 ++--------------- .../dydxTakeProfitStopLossViewModel.swift | 1 - .../Trade/Margin/dydxTargetLeverageView.swift | 11 +- .../dydxTradeInputMarginView.swift | 2 + 13 files changed, 473 insertions(+), 205 deletions(-) create mode 100644 dydx/dydxFormatter/dydxFormatter/dydxNumberInputFormatter.swift create mode 100644 dydx/dydxViews/dydxViews/Shared/dydxComponents/dydxSliderInputView.swift create mode 100644 dydx/dydxViews/dydxViews/Shared/dydxComponents/dydxSliderView.swift create mode 100644 dydx/dydxViews/dydxViews/Shared/dydxComponents/dydxTitledNumberField.swift diff --git a/Utilities/Utilities/_Extensions/Double+String.swift b/Utilities/Utilities/_Extensions/Double+String.swift index c0b1bc689..490d74a84 100644 --- a/Utilities/Utilities/_Extensions/Double+String.swift +++ b/Utilities/Utilities/_Extensions/Double+String.swift @@ -35,37 +35,6 @@ extension Double { let divisor = pow(10.0, Double(places)) return (self * divisor).rounded() / divisor } - - public func round(size: Double) -> Double { - return round(to: places(size: size)) - } - - private func places(size: Double) -> Int { - switch size { - case 1000.0: - return -3 - case 100.0: - return -2 - case 10.0: - return -1 - case 1.0: - return 0 - case 0.1: - return 1 - case 0.01: - return 2 - case 0.001: - return 3 - case 0.0001: - return 4 - case 0.00001: - return 5 - case 0.000001: - return 6 - default: - return 0 - } - } public var stringValue: String? { "\(self)" } } diff --git a/dydx/dydxFormatter/dydxFormatter.xcodeproj/project.pbxproj b/dydx/dydxFormatter/dydxFormatter.xcodeproj/project.pbxproj index 62dbd5ac0..91b313e71 100644 --- a/dydx/dydxFormatter/dydxFormatter.xcodeproj/project.pbxproj +++ b/dydx/dydxFormatter/dydxFormatter.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 02841E3A29AD6E5500C0E7CC /* dydxFormatter.h in Headers */ = {isa = PBXBuildFile; fileRef = 02841E2C29AD6E5500C0E7CC /* dydxFormatter.h */; settings = {ATTRIBUTES = (Public, ); }; }; 02841ECB29AD6E8D00C0E7CC /* dydxFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02841ECA29AD6E8D00C0E7CC /* dydxFormatter.swift */; }; 02841F6529AD6F1100C0E7CC /* Utilities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02841F5A29AD6EF100C0E7CC /* Utilities.framework */; platformFilter = ios; }; + 27DBF3CB2C4AAC8A009EB2D6 /* dydxNumberInputFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27DBF3CA2C4AAC8A009EB2D6 /* dydxNumberInputFormatter.swift */; }; D50ABD228F90F45E822E160E /* Pods_iOS_dydxFormatterTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E8B6B79D7CF404CE544872C5 /* Pods_iOS_dydxFormatterTests.framework */; }; E16B15943DC7937C8F9D87D6 /* Pods_iOS_dydxFormatter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2782DAD1615D2E23FD387E79 /* Pods_iOS_dydxFormatter.framework */; }; /* End PBXBuildFile section */ @@ -131,6 +132,7 @@ 02841F5129AD6EF100C0E7CC /* Utilities.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Utilities.xcodeproj; path = ../../Utilities/Utilities.xcodeproj; sourceTree = ""; }; 177FCA2765FA7FAE4B4AF56F /* Pods-iOS-dydxFormatterTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-dydxFormatterTests.debug.xcconfig"; path = "Target Support Files/Pods-iOS-dydxFormatterTests/Pods-iOS-dydxFormatterTests.debug.xcconfig"; sourceTree = ""; }; 2782DAD1615D2E23FD387E79 /* Pods_iOS_dydxFormatter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_dydxFormatter.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 27DBF3CA2C4AAC8A009EB2D6 /* dydxNumberInputFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dydxNumberInputFormatter.swift; sourceTree = ""; }; 46DBD879AB8325C51F5F9732 /* Pods-iOS-dydxFormatterTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-dydxFormatterTests.release.xcconfig"; path = "Target Support Files/Pods-iOS-dydxFormatterTests/Pods-iOS-dydxFormatterTests.release.xcconfig"; sourceTree = ""; }; 5172233B016C14B302F6B1C0 /* Pods-iOS-dydxFormatter.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-dydxFormatter.debug.xcconfig"; path = "Target Support Files/Pods-iOS-dydxFormatter/Pods-iOS-dydxFormatter.debug.xcconfig"; sourceTree = ""; }; CB164F70916C1A8A28D6A263 /* Pods-iOS-dydxFormatter.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-dydxFormatter.release.xcconfig"; path = "Target Support Files/Pods-iOS-dydxFormatter/Pods-iOS-dydxFormatter.release.xcconfig"; sourceTree = ""; }; @@ -220,6 +222,7 @@ 02841ECA29AD6E8D00C0E7CC /* dydxFormatter.swift */, 02841E2C29AD6E5500C0E7CC /* dydxFormatter.h */, 02841E2D29AD6E5500C0E7CC /* dydxFormatter.docc */, + 27DBF3CA2C4AAC8A009EB2D6 /* dydxNumberInputFormatter.swift */, ); path = dydxFormatter; sourceTree = ""; @@ -536,6 +539,7 @@ 0262F17A29DB4165009889E2 /* dydxPApi.swift in Sources */, 0262F17B29DB4168009889E2 /* dydxPublicApi.swift in Sources */, 0262F17929DB4163009889E2 /* dydxClientSourceInjection.swift in Sources */, + 27DBF3CB2C4AAC8A009EB2D6 /* dydxNumberInputFormatter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/dydx/dydxFormatter/dydxFormatter/dydxNumberInputFormatter.swift b/dydx/dydxFormatter/dydxFormatter/dydxNumberInputFormatter.swift new file mode 100644 index 000000000..676785470 --- /dev/null +++ b/dydx/dydxFormatter/dydxFormatter/dydxNumberInputFormatter.swift @@ -0,0 +1,45 @@ +// +// dydxNumberInputFormatter.swift +// dydxFormatter +// +// Created by Michael Maguire on 7/19/24. +// + +import Foundation + +/// a number formatter that also supports rounding to nearest 10/100/1000/etc +/// formatter is intended for user inputs, so group separator is omitted, i.e. the "," in "1,000" +public class dydxNumberInputFormatter: NumberFormatter, ObservableObject { + + /// if greater than 0, numbers will be rounded to nearest 10, 100, 1000, etc. If less than 0 numbers will be rounded to nearest 0.1, 0.01, .001 + public var fractionDigits: Int { + get { + maximumFractionDigits + } + set { + if maximumFractionDigits != newValue || minimumFractionDigits != newValue { + maximumFractionDigits = newValue + minimumFractionDigits = newValue + objectWillChange.send() + } + } + } + + /// Use this initializer + /// - Parameter fractionDigits: if greater than 0, numbers will be rounded to nearest 10, 100, 1000, etc. If less than 0 numbers will be rounded to nearest 0.1, 0.01, .001 + public convenience init(fractionDigits: Int = 2) { + self.init() + self.maximumFractionDigits = fractionDigits + self.minimumFractionDigits = fractionDigits + self.numberStyle = .decimal + self.usesGroupingSeparator = false + } + + public override func string(from number: NSNumber) -> String? { + if maximumFractionDigits < 0 { + return String(Int(number.doubleValue.round(to: maximumFractionDigits))) + } else { + return super.string(from: number) + } + } +} diff --git a/dydx/dydxPresenters/dydxPresenters/_v4/TakeProfitStopLoss/dydxTakeProfitStopLossViewPresenter.swift b/dydx/dydxPresenters/dydxPresenters/_v4/TakeProfitStopLoss/dydxTakeProfitStopLossViewPresenter.swift index 5fad85059..9895e314f 100644 --- a/dydx/dydxPresenters/dydxPresenters/_v4/TakeProfitStopLoss/dydxTakeProfitStopLossViewPresenter.swift +++ b/dydx/dydxPresenters/dydxPresenters/_v4/TakeProfitStopLoss/dydxTakeProfitStopLossViewPresenter.swift @@ -53,13 +53,24 @@ private class dydxTakeProfitStopLossViewPresenter: HostedViewPresenter 0 { + viewModel?.customAmountViewModel?.sliderTextInput.numberFormatter.fractionDigits = Int(-log10(stepSize)) + } } private func update(subaccountPositions: [SubaccountPosition], triggerOrdersInput: TriggerOrdersInput?, errors: [ValidationError], configsMap: [String: MarketConfigsAndAsset]) { @@ -169,8 +183,8 @@ private class dydxTakeProfitStopLossViewPresenter: HostedViewPresenter 0 { + if value > 0 { viewModel?.ctaButton?.ctaButtonState = .enabled(DataLocalizer.localize(path: "APP.TRADE.CONFIRM_LEVERAGE")) } else { viewModel?.ctaButton?.ctaButtonState = .disabled(DataLocalizer.localize(path: "APP.TRADE.CONFIRM_LEVERAGE")) @@ -93,14 +97,14 @@ private class dydxTargetLeverageViewPresenter: HostedViewPresenter 0 { let maxLeverage = 1.0 / effectiveInitialMarginFraction + viewModel.sliderTextInput.maxValue = maxLeverage viewModel.leverageOptions = [1, 2, 3, 5, 10] .filter { $0 < maxLeverage } .map { dydxTargetLeverageViewModel.LeverageTextAndValue(text: dydxFormatter.shared.multiplier(number: Double($0)) ?? "", value: $0) } viewModel.leverageOptions.append(dydxTargetLeverageViewModel.LeverageTextAndValue(text: DataLocalizer.localize(path: "APP.GENERAL.MAX"), value: maxLeverage)) } - let value = dydxFormatter.shared.localFormatted(number: tradeInput?.targetLeverage ?? 1, digits: 1) - self?.update(value: value) + self?.update(value: tradeInput?.targetLeverage ?? 1) } .store(in: &subscriptions) } diff --git a/dydx/dydxViews/dydxViews.xcodeproj/project.pbxproj b/dydx/dydxViews/dydxViews.xcodeproj/project.pbxproj index a9fecbedb..91e65266e 100644 --- a/dydx/dydxViews/dydxViews.xcodeproj/project.pbxproj +++ b/dydx/dydxViews/dydxViews.xcodeproj/project.pbxproj @@ -168,11 +168,13 @@ 2728CE1B2BBCD2AB004C9323 /* dydxGainLossInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2728CE1A2BBCD2AB004C9323 /* dydxGainLossInputViewModel.swift */; }; 2729123E2C06A775003F3EA0 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 2729123D2C06A775003F3EA0 /* Introspect */; }; 272912402C06A780003F3EA0 /* KeyboardObserving in Frameworks */ = {isa = PBXBuildFile; productRef = 2729123F2C06A780003F3EA0 /* KeyboardObserving */; }; + 273C2F382C496F4F00F8391F /* dydxSliderInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 273C2F372C496F4F00F8391F /* dydxSliderInputView.swift */; }; 273F50162B7C3F120034792A /* SignedAmountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 273F50152B7C3F120034792A /* SignedAmountView.swift */; }; 274C47F02C0FC9A4000212C3 /* MemoBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 274C47EF2C0FC9A4000212C3 /* MemoBox.swift */; }; 27685F4D2B9FCAE200F37DE2 /* Satoshi-Medium.otf in Resources */ = {isa = PBXBuildFile; fileRef = 27685F402B9FCAD300F37DE2 /* Satoshi-Medium.otf */; }; 2769090E2AAFD8030075B2D6 /* TransferInstanceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2769090D2AAFD8030075B2D6 /* TransferInstanceViewModel.swift */; }; 276909102AAFD8BE0075B2D6 /* dydxPortfolioTransfersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2769090F2AAFD8BE0075B2D6 /* dydxPortfolioTransfersViewModel.swift */; }; + 276AF8C72C4AE85A001DD695 /* dydxSliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 276AF8C62C4AE85A001DD695 /* dydxSliderView.swift */; }; 277442972AD88C4900C91357 /* Satoshi-Bold.otf in Resources */ = {isa = PBXBuildFile; fileRef = 277442952AD88C4900C91357 /* Satoshi-Bold.otf */; }; 277442982AD88C4900C91357 /* Satoshi-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = 277442962AD88C4900C91357 /* Satoshi-Regular.otf */; }; 27759F5C2B89125F002865A9 /* dydxInlineShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27759F5B2B89125F002865A9 /* dydxInlineShareView.swift */; }; @@ -194,6 +196,7 @@ 27C027452AFD734800E92CCB /* dydxSettingsHelpRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C027442AFD734800E92CCB /* dydxSettingsHelpRowView.swift */; }; 27C6E4C92BC8C30E00ED892A /* dydxCustomLimitPriceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27C6E4BC2BC8C30E00ED892A /* dydxCustomLimitPriceViewModel.swift */; }; 27CDA3D42BBF1AD700FEAFFE /* dydxMultipleOrdersExistViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CDA3D32BBF1AD700FEAFFE /* dydxMultipleOrdersExistViewModel.swift */; }; + 27DBF3C92C4A05B9009EB2D6 /* dydxTitledNumberField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27DBF3C82C4A05B9009EB2D6 /* dydxTitledNumberField.swift */; }; 27E072D22C1A095C0034B963 /* dydxPortfolioPendingPositionsItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E072D12C1A095C0034B963 /* dydxPortfolioPendingPositionsItemViewModel.swift */; }; 27E0736B2C20D27F0034B963 /* dydxCancelPendingIsolatedOrdersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E0736A2C20D27F0034B963 /* dydxCancelPendingIsolatedOrdersView.swift */; }; 27ED340C2AD47CB100C159F5 /* dydxBannerErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27ED340B2AD47CB100C159F5 /* dydxBannerErrorAlert.swift */; }; @@ -538,12 +541,14 @@ 271010462BC7454B0037091A /* dydxCustomAmountViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = dydxCustomAmountViewModel.swift; sourceTree = ""; }; 272030172A7812B900D233B9 /* UINavigationController+SwipeBackNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationController+SwipeBackNavigation.swift"; sourceTree = ""; }; 2728CE1A2BBCD2AB004C9323 /* dydxGainLossInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dydxGainLossInputViewModel.swift; sourceTree = ""; }; + 273C2F372C496F4F00F8391F /* dydxSliderInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dydxSliderInputView.swift; sourceTree = ""; }; 273F50152B7C3F120034792A /* SignedAmountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignedAmountView.swift; sourceTree = ""; }; 2742C04D2BF6897A00E13C09 /* dydxAnalytics.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = dydxAnalytics.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 274C47EF2C0FC9A4000212C3 /* MemoBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoBox.swift; sourceTree = ""; }; 27685F402B9FCAD300F37DE2 /* Satoshi-Medium.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Satoshi-Medium.otf"; sourceTree = ""; }; 2769090D2AAFD8030075B2D6 /* TransferInstanceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferInstanceViewModel.swift; sourceTree = ""; }; 2769090F2AAFD8BE0075B2D6 /* dydxPortfolioTransfersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dydxPortfolioTransfersViewModel.swift; sourceTree = ""; }; + 276AF8C62C4AE85A001DD695 /* dydxSliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dydxSliderView.swift; sourceTree = ""; }; 277442952AD88C4900C91357 /* Satoshi-Bold.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Satoshi-Bold.otf"; sourceTree = ""; }; 277442962AD88C4900C91357 /* Satoshi-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Satoshi-Regular.otf"; sourceTree = ""; }; 27759F5B2B89125F002865A9 /* dydxInlineShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dydxInlineShareView.swift; sourceTree = ""; }; @@ -565,6 +570,7 @@ 27C027442AFD734800E92CCB /* dydxSettingsHelpRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dydxSettingsHelpRowView.swift; sourceTree = ""; }; 27C6E4BC2BC8C30E00ED892A /* dydxCustomLimitPriceViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = dydxCustomLimitPriceViewModel.swift; sourceTree = ""; }; 27CDA3D32BBF1AD700FEAFFE /* dydxMultipleOrdersExistViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dydxMultipleOrdersExistViewModel.swift; sourceTree = ""; }; + 27DBF3C82C4A05B9009EB2D6 /* dydxTitledNumberField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dydxTitledNumberField.swift; sourceTree = ""; }; 27E072D12C1A095C0034B963 /* dydxPortfolioPendingPositionsItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dydxPortfolioPendingPositionsItemViewModel.swift; sourceTree = ""; }; 27E0736A2C20D27F0034B963 /* dydxCancelPendingIsolatedOrdersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dydxCancelPendingIsolatedOrdersView.swift; sourceTree = ""; }; 27ED340B2AD47CB100C159F5 /* dydxBannerErrorAlert.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = dydxBannerErrorAlert.swift; sourceTree = ""; }; @@ -712,7 +718,6 @@ 0258BA0F2992947E0098E1BE /* Profile */, 02107A72297A2CCB000D75E1 /* Receipt */, 02F99F1B29E4BF720009B3E8 /* Search */, - 27044F712BBB1D44004C750D /* TakeProfitStopLoss */, 024F488029657E3900E40247 /* Trade */, 02A5C84A297FBCAA00FFE1F9 /* Transfer */, 020DBF0129E0900F0068AAA6 /* Wallet */, @@ -920,6 +925,7 @@ 024FEB7D2ACB82A10087A55E /* NavHeader.swift */, 0273A15E2ACCDDB1001B89F5 /* SelectionBar.swift */, 273F50152B7C3F120034792A /* SignedAmountView.swift */, + 27D276222C519C98002775F2 /* dydxComponents */, ); path = Shared; sourceTree = ""; @@ -1017,6 +1023,7 @@ 024F488029657E3900E40247 /* Trade */ = { isa = PBXGroup; children = ( + 27044F712BBB1D44004C750D /* TakeProfitStopLoss */, 023AB3C12BEAD544005230B2 /* Margin */, 024B44BE2982D18800E35D54 /* TradeInput */, 024B44CF2982D1C900E35D54 /* TradeStatus */, @@ -1440,7 +1447,8 @@ 27044F952BBC6D26004C750D /* Components */, 27044F7E2BBB1D5A004C750D /* dydxTakeProfitStopLossViewModel.swift */, ); - path = TakeProfitStopLoss; + name = TakeProfitStopLoss; + path = ../TakeProfitStopLoss; sourceTree = ""; }; 27044F952BBC6D26004C750D /* Components */ = { @@ -1505,6 +1513,16 @@ path = Banner; sourceTree = ""; }; + 27D276222C519C98002775F2 /* dydxComponents */ = { + isa = PBXGroup; + children = ( + 27DBF3C82C4A05B9009EB2D6 /* dydxTitledNumberField.swift */, + 273C2F372C496F4F00F8391F /* dydxSliderInputView.swift */, + 276AF8C62C4AE85A001DD695 /* dydxSliderView.swift */, + ); + path = dydxComponents; + sourceTree = ""; + }; 27E0735D2C20D2680034B963 /* CancelOrders */ = { isa = PBXGroup; children = ( @@ -1998,8 +2016,10 @@ 0279DE892BED402C00F9ECF8 /* dydxAdjustMarginPercentageView.swift in Sources */, 026382EB28F0EF0000F766FA /* dydxMarketPriceCandlesResolutionsView.swift in Sources */, 02F99F3E29E4D5750009B3E8 /* dydxTransferSearchItemView.swift in Sources */, + 27DBF3C92C4A05B9009EB2D6 /* dydxTitledNumberField.swift in Sources */, 0284201629AD71B600C0E7CC /* Enums.swift in Sources */, 02678FA629666BE600EE346B /* dydxPortfolioOrdersView.swift in Sources */, + 273C2F382C496F4F00F8391F /* dydxSliderInputView.swift in Sources */, 020DBF0F29E0924E0068AAA6 /* dydxTransferDepositView.swift in Sources */, 277E90412B1FAEAF005CCBCB /* dydxRewardsHelpView.swift in Sources */, 02714C9F29E0C7C500CC1C44 /* TokensComboBox.swift in Sources */, @@ -2059,6 +2079,7 @@ 27BAAF112BD851B500F650C3 /* dydxTakeProfitStopLossStatusViewModel.swift in Sources */, 02E51E5F29FB167F00BC0236 /* ImageFactory.swift in Sources */, 64A4DB6B2966215A008D8E20 /* dydxOrderbookSideView.swift in Sources */, + 276AF8C72C4AE85A001DD695 /* dydxSliderView.swift in Sources */, 02714C9D29E0C78600CC1C44 /* ChainsComboBox.swift in Sources */, 02B8419D28EF68C300C4D25B /* dydxMarketInfoView.swift in Sources */, 02D3C46E28F74F5400683843 /* dydxMarketResourcesView.swift in Sources */, diff --git a/dydx/dydxViews/dydxViews/Shared/dydxComponents/dydxSliderInputView.swift b/dydx/dydxViews/dydxViews/Shared/dydxComponents/dydxSliderInputView.swift new file mode 100644 index 000000000..0a3344d06 --- /dev/null +++ b/dydx/dydxViews/dydxViews/Shared/dydxComponents/dydxSliderInputView.swift @@ -0,0 +1,70 @@ +// +// dydxSliderInputView.swift +// dydxViews +// +// Created by Michael Maguire on 7/18/24. +// + +import SwiftUI +import PlatformUI +import dydxFormatter +import Utilities +import Combine + +public class dydxSliderInputViewModel: PlatformViewModel { + public let title: String? + @Published public var accessoryTitle: String? + @Published public var minValue: Double = 0 + @Published public var maxValue: Double = 0 + @Published public private(set) var valueAsString: String = "" + @Published public var value: Double? { + didSet { + valueAsString = value.map { numberFormatter.string(from: $0 as NSNumber) ?? "" } ?? "" + } + } + + @Published public private(set) var numberFormatter = dydxNumberInputFormatter() + + init(title: String?, accessoryTitle: String? = nil) { + self.title = title + self.accessoryTitle = accessoryTitle + } + + public override func createView(parentStyle: ThemeStyle = ThemeStyle.defaultStyle, styleKey: String? = nil) -> PlatformView { + PlatformView(viewModel: self, parentStyle: parentStyle, styleKey: styleKey) { [weak self] _ in + guard let self = self else { return PlatformView.emptyView.wrappedInAnyView() } + return dydxSliderTextInput(viewModel: self).wrappedInAnyView() + } + } +} + +private struct dydxSliderTextInput: View { + @ObservedObject var viewModel: dydxSliderInputViewModel + + var slider: some View { + dydxSlider(minValue: viewModel.minValue, + maxValue: viewModel.maxValue, + precision: viewModel.numberFormatter.fractionDigits, + value: $viewModel.value) + } + + var textInput: some View { + dydxTitledNumberField(title: viewModel.title, + accessoryTitle: viewModel.accessoryTitle, + numberFormatter: viewModel.numberFormatter, + minValue: viewModel.minValue, + maxValue: viewModel.maxValue, + value: $viewModel.value) + } + + var body: some View { + HStack(alignment: .center, spacing: 16) { + slider + textInput + .makeInput() + // min 114 is the min size and fixed size will allow it to expand to keep title in one line + .frame(minWidth: 114) + .fixedSize() + } + } +} diff --git a/dydx/dydxViews/dydxViews/Shared/dydxComponents/dydxSliderView.swift b/dydx/dydxViews/dydxViews/Shared/dydxComponents/dydxSliderView.swift new file mode 100644 index 000000000..9f88d7b39 --- /dev/null +++ b/dydx/dydxViews/dydxViews/Shared/dydxComponents/dydxSliderView.swift @@ -0,0 +1,93 @@ +// +// dydxSliderView.swift +// dydxViews +// +// Created by Michael Maguire on 7/19/24. +// + +import SwiftUI +import PlatformUI + +struct dydxSlider: View { + var minValue: Double + var maxValue: Double + var precision: Int + @Binding var value: Double? + + private let thumbRadius: CGFloat = 11 + + var body: some View { + + GeometryReader { geometry in + ZStack(alignment: .leading) { + track(width: geometry.size.width) + cursor(geometry: geometry) + } + } + .frame(height: thumbRadius * 2) + } + + private func cursor(geometry: GeometryProxy) -> some View { + let draggableLength = (geometry.size.width - thumbRadius * 2) + var dragPortion: Double = 0 + if let value = value { + dragPortion = (value - minValue)/(maxValue - minValue) * draggableLength + } + let xOffset = min(max(0, dragPortion), draggableLength) + return Rectangle() + .fill(ThemeColor.SemanticColor.layer7.color) + .frame(width: thumbRadius * 2, height: thumbRadius * 2) + .borderAndClip(style: .circle, borderColor: .textTertiary) + .offset(x: xOffset) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged({ gesture in + updateValue(with: gesture, geometry: geometry) + }) + ) + } + + private func track(width: Double) -> some View { + let tickWidth = 1.5 + let tickSpacing = 5.0 + let spacerIndent = thumbRadius - tickWidth/2 + let tickAreaWidth = (width - spacerIndent * 2) + // there is one more tick than there is a space, so subtract an extra tick width from the total available width + // then round up and adjust tick spacing down if necessary + let numTicks = ((tickAreaWidth - tickWidth) / (tickWidth + tickSpacing) + 1).rounded(.up) + let numSpacers = numTicks - 1 + let tickSpacingAdjusted = (tickAreaWidth - (numTicks * tickWidth))/numSpacers + + let trackIndentSpacer = Spacer() + .frame(width: spacerIndent) + return HStack(spacing: 0) { + trackIndentSpacer + HStack(spacing: tickSpacingAdjusted) { + ForEach(0.. geometry.size.width - thumbRadius * 2 { + value = maxValue + print("value maxValue: \(value!)") + } else { + let dragPortion = dragTouchLocationCentered / (geometry.size.width - thumbRadius * 2) + let newValue = (maxValue - minValue) * dragPortion + minValue + value = min(max(newValue, minValue), maxValue).round(to: precision) + print("value: \(value!)") + } + } +} diff --git a/dydx/dydxViews/dydxViews/Shared/dydxComponents/dydxTitledNumberField.swift b/dydx/dydxViews/dydxViews/Shared/dydxComponents/dydxTitledNumberField.swift new file mode 100644 index 000000000..9398b9b78 --- /dev/null +++ b/dydx/dydxViews/dydxViews/Shared/dydxComponents/dydxTitledNumberField.swift @@ -0,0 +1,143 @@ +// +// dydxNumberFieldView.swift +// dydxViews +// +// Created by Michael Maguire on 7/18/24. +// + +import SwiftUI +import dydxFormatter +import PlatformUI + +/// Effectively a TextField which forces its input as a number +/// Supports dydx-style title and title accesory view +struct dydxTitledNumberField: View { + let title: String? + let accessoryTitle: String? + let numberFormatter: dydxNumberInputFormatter + let minValue: Double + let maxValue: Double + @Binding var value: Double? + @State private var textWidth: CGFloat = 0 + + let minWidth: CGFloat = 100 + + @ViewBuilder + private var accessoryTitleView: some View { + if let text = accessoryTitle { + TokenTextViewModel(symbol: text) + .createView(parentStyle: .defaultStyle.themeFont(fontType: .base, fontSize: .smallest)) + } + } + + @ViewBuilder + private var titleView: some View { + if let text = title { + Text(text) + .themeColor(foreground: .textTertiary) + .themeFont(fontType: .base, fontSize: .smaller) + .background( + // this ensures that the text does not grow wider than the title or min width + GeometryReader { geometry in + Color.clear + .onAppear { + textWidth = max(geometry.size.width, minWidth) + } + } + ) + .fixedSize() + } + } + + private var textFieldView: some View { + NumberTextField( + actualValue: $value, + minValue: minValue, + maxValue: maxValue, + numberFormatter: numberFormatter) + .themeColor(foreground: .textPrimary) + .themeFont(fontType: .base, fontSize: .medium) + .truncationMode(.middle) + .frame(width: textWidth) + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 5) { + titleView + accessoryTitleView + } + textFieldView + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .makeInput() + } +} + +/// formats input as it is received +private struct NumberTextField: View { + @Binding var actualValue: Double? + @FocusState private var isFocused: Bool + + let minValue: Double + let maxValue: Double + let numberFormatter: dydxNumberInputFormatter + + private var keyboardType: UIKeyboardType { + numberFormatter.fractionDigits > 0 ? .decimalPad : .numberPad + } + + @ViewBuilder + private var placeholder: some View { + Text(numberFormatter.string(for: Double.zero) ?? "") + .themeFont(fontType: .base, fontSize: .medium) + .themeColor(foreground: .textTertiary) + } + + var body: some View { + TextField(text: filteredTextBinding) { + placeholder + } + .keyboardType(keyboardType) + .focused($isFocused) + .onChange(of: isFocused) { _ in + if !isFocused, let value = Double(filteredTextBinding.wrappedValue) { + actualValue = formatValue(value) + } + } + .keyboardAccessory(parentStyle: .defaultStyle) + } + + private func formatValue(_ value: Double?) -> Double? { + guard let value = value else { + return nil + } + let multiplier = pow(10.0, Double(numberFormatter.fractionDigits)) + let formattedValue = (value * multiplier).rounded() / multiplier + return formattedValue + } + + private var filteredTextBinding: Binding { + Binding( + get: { + if let value = actualValue { + return numberFormatter.string(from: NSNumber(value: value)) ?? "" + } else { + return "" + } + }, + set: { newValue in + if let doubleValue = Double(newValue) { + actualValue = formatValue(clamp(doubleValue)) + } else { + actualValue = nil + } + } + ) + } + + private func clamp(_ value: Double) -> Double { + min(max(value, minValue), maxValue) + } +} diff --git a/dydx/dydxViews/dydxViews/_v4/TakeProfitStopLoss/Components/dydxCustomAmountViewModel.swift b/dydx/dydxViews/dydxViews/_v4/TakeProfitStopLoss/Components/dydxCustomAmountViewModel.swift index 6146c873f..825da84e1 100644 --- a/dydx/dydxViews/dydxViews/_v4/TakeProfitStopLoss/Components/dydxCustomAmountViewModel.swift +++ b/dydx/dydxViews/dydxViews/_v4/TakeProfitStopLoss/Components/dydxCustomAmountViewModel.swift @@ -10,51 +10,24 @@ import SwiftUI import PlatformUI import dydxFormatter import Utilities +import Combine -public class dydxCustomAmountViewModel: PlatformTextInputViewModel { +public class dydxCustomAmountViewModel: PlatformViewModel { - @Published private (set) public var isOn: Bool = false + @Published public var isOn: Bool = false @Published public var toggleAction: ((Bool) -> Void)? - @Published public var assetId: String? { - didSet { - guard let assetId = assetId else { return } - labelAccessory = TokenTextViewModel(symbol: assetId) - .createView(parentStyle: ThemeStyle.defaultStyle.themeFont(fontSize: .smallest)) - .wrappedInAnyView() - } - } - @Published public var stepSize: String? { - didSet { - guard let stepSize else { return } - self.placeHolder = dydxFormatter.shared.raw(number: .zero, size: stepSize) - } - } - @Published public var minimumValue: Float? { - didSet { - slider.minimumValue = minimumValue ?? 0 - } - } - @Published public var maximumValue: Float? { - didSet { - slider.maximumValue = maximumValue ?? 0 - } - } - - /// Sets value and related UI without triggering action callbacks - public func programmaticallySet(newValue: String?) { - isOn = newValue != nil - setSliderValue(value: newValue) - value = newValue + public var valuePublisher: AnyPublisher { + Publishers.CombineLatest($isOn, sliderTextInput.$valueAsString) + .map { isOn, value in + isOn ? value : nil + } + .eraseToAnyPublisher() } - public init() { - super.init( - label: DataLocalizer.shared?.localize(path: "APP.GENERAL.AMOUNT", params: nil), - inputType: .decimalDigits, - truncateMode: .middle - ) - } + @Published public var sliderTextInput = dydxSliderInputViewModel( + title: DataLocalizer.localize(path: "APP.GENERAL.AMOUNT") + ) private var onOffSwitch: some View { PlatformBooleanInputViewModel(label: DataLocalizer.shared?.localize(path: "APP.GENERAL.CUSTOM_AMOUNT", params: nil), labelAccessory: nil, value: isOn.description, valueAccessoryView: nil) { [weak self] value in @@ -66,107 +39,29 @@ public class dydxCustomAmountViewModel: PlatformTextInputViewModel { .padding(.trailing, 2) // swiftui bug where toggle view in a scrollview gets clipped without this } - private var input: AnyView? { - guard isOn else { return nil } - return super.createView() - .makeInput() - .wrappedInAnyView() - } - - private lazy var slider: UISlider = { - let slider = UISlider(frame: .zero) - slider.value = 0 - slider.thumbTintColor = ThemeColor.SemanticColor.textSecondary.uiColor - slider.minimumTrackTintColor = .clear - slider.maximumTrackTintColor = .clear - slider.addTarget( - self, - action: #selector(self.sliderValueChanged), - for: .valueChanged - ) - - return slider - }() - - private var sliderBackground: some View { - GeometryReader { geometry in - let tickWidth = 1.5 - let tickSpacing = 5.0 - let numTicks = (geometry.size.width - tickSpacing * 2) / (tickWidth + tickSpacing) - return HStack(spacing: tickSpacing) { - Spacer() - HStack(spacing: tickSpacing) { - ForEach(0.. PlatformView { PlatformView(viewModel: self, parentStyle: parentStyle, styleKey: styleKey) { [weak self] _ in // if min == max, there is no need for a custom amount since there can only be one value - guard let self = self, minimumValue != maximumValue else { return PlatformView.emptyView.wrappedInAnyView() } + guard let self = self else { return PlatformView.emptyView.wrappedInAnyView() } return VStack(spacing: 15) { self.onOffSwitch HStack(alignment: .center, spacing: 20) { if self.isOn { - self.sliderCompositeView - self.input + self.sliderTextInput.createView() } } } .wrappedInAnyView() } } - - @objc private func sliderValueChanged(sender: UISlider) { - guard let stepSize else { return } - let roundedValue = dydxFormatter.shared.raw(number: NSNumber(value: sender.value), size: stepSize) - self.value = roundedValue - slider.value = Parser.standard.asInputDecimal(value)?.floatValue ?? minimumValue ?? 0 - PlatformView.hideKeyboard() - self.onEdited?(value) - } - - public override func valueChanged(value: String?) { - if let stepSize, - let value = Parser.standard.asInputDecimal(value)?.floatValue { - let valueWithinRange = min(max(slider.minimumValue, value), slider.maximumValue) - let roundedValue = dydxFormatter.shared.raw(number: Parser.standard.asInputDecimal(valueWithinRange), size: stepSize) - super.valueChanged(value: roundedValue) - } else { - super.valueChanged(value: "\(minimumValue ?? 0)") - } - setSliderValue(value: value) - } - - private func setSliderValue(value: String?) { - slider.value = Parser.standard.asDecimal(value)?.floatValue ?? slider.minimumValue - } } #if DEBUG diff --git a/dydx/dydxViews/dydxViews/_v4/TakeProfitStopLoss/dydxTakeProfitStopLossViewModel.swift b/dydx/dydxViews/dydxViews/_v4/TakeProfitStopLoss/dydxTakeProfitStopLossViewModel.swift index 9d05302a8..bfdface66 100644 --- a/dydx/dydxViews/dydxViews/_v4/TakeProfitStopLoss/dydxTakeProfitStopLossViewModel.swift +++ b/dydx/dydxViews/dydxViews/_v4/TakeProfitStopLoss/dydxTakeProfitStopLossViewModel.swift @@ -153,7 +153,6 @@ public class dydxTakeProfitStopLossViewModel: PlatformViewModel { } } .keyboardObserving() - .keyboardAccessory(background: .layer3, parentStyle: parentStyle) Spacer(minLength: 18) self.createCta(parentStyle: parentStyle, styleKey: styleKey) } diff --git a/dydx/dydxViews/dydxViews/_v4/Trade/Margin/dydxTargetLeverageView.swift b/dydx/dydxViews/dydxViews/_v4/Trade/Margin/dydxTargetLeverageView.swift index d727ff2c9..643d65e7e 100644 --- a/dydx/dydxViews/dydxViews/_v4/Trade/Margin/dydxTargetLeverageView.swift +++ b/dydx/dydxViews/dydxViews/_v4/Trade/Margin/dydxTargetLeverageView.swift @@ -9,6 +9,7 @@ import SwiftUI import PlatformUI import Utilities +import dydxFormatter public class dydxTargetLeverageViewModel: PlatformViewModel { public struct LeverageTextAndValue { @@ -25,10 +26,9 @@ public class dydxTargetLeverageViewModel: PlatformViewModel { @Published public var leverageOptions: [LeverageTextAndValue] = [] @Published public var selectedOptionIndex: Int? @Published public var optionSelectedAction: ((LeverageTextAndValue) -> Void)? - @Published public var leverageInput: PlatformTextInputViewModel? = - PlatformTextInputViewModel(label: DataLocalizer.localize(path: "APP.TRADE.TARGET_LEVERAGE"), - placeHolder: "0.0", - inputType: PlatformTextInputViewModel.InputType.decimalDigits) + @Published public var sliderTextInput = dydxSliderInputViewModel( + title: DataLocalizer.localize(path: "APP.TRADE.TARGET_LEVERAGE") + ) @Published public var ctaButton: dydxTargetLeverageCtaButtonViewModel? = dydxTargetLeverageCtaButtonViewModel() public init() { } @@ -55,9 +55,8 @@ public class dydxTargetLeverageViewModel: PlatformViewModel { .leftAligned() .themeFont(fontSize: .medium) - self.leverageInput? + self.sliderTextInput .createView(parentStyle: style) - .makeInput() self.createOptionsGroup(parentStyle: style) diff --git a/dydx/dydxViews/dydxViews/_v4/Trade/TradeInput/Components/TradeInputFields/dydxTradeInputMarginView.swift b/dydx/dydxViews/dydxViews/_v4/Trade/TradeInput/Components/TradeInputFields/dydxTradeInputMarginView.swift index 218669b2d..015b9093a 100644 --- a/dydx/dydxViews/dydxViews/_v4/Trade/TradeInput/Components/TradeInputFields/dydxTradeInputMarginView.swift +++ b/dydx/dydxViews/dydxViews/_v4/Trade/TradeInput/Components/TradeInputFields/dydxTradeInputMarginView.swift @@ -39,6 +39,7 @@ public class dydxTradeInputMarginViewModel: PlatformViewModel { .themeFont(fontSize: .smaller) .themeColor(foreground: .textTertiary) } + .lineLimit(1) Spacer() } .wrappedViewModel @@ -62,6 +63,7 @@ public class dydxTradeInputMarginViewModel: PlatformViewModel { .themeFont(fontType: .plus, fontSize: .smaller) .themeColor(foreground: .textTertiary) } + .lineLimit(1) .padding(.horizontal, 16) .wrappedViewModel