diff --git a/kmm-form-validation/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/control/InputControl.kt b/kmm-form-validation/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/control/InputControl.kt index f8c4d58..d3384fc 100644 --- a/kmm-form-validation/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/control/InputControl.kt +++ b/kmm-form-validation/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/control/InputControl.kt @@ -22,7 +22,7 @@ class InputControl( val maxLength: Int = Int.MAX_VALUE, val keyboardOptions: KeyboardOptions, val textTransformation: TextTransformation? = null, - val visualTransformation: VisualTransformation = VisualTransformation.Companion.None + val visualTransformation: VisualTransformation = VisualTransformation.None ) : ValidatableControl { constructor(coroutineScope: CoroutineScope) : this( diff --git a/kmm-form-validation/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/options/VisualTransformation.kt b/kmm-form-validation/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/options/VisualTransformation.kt index f6fbfd5..4e615e5 100644 --- a/kmm-form-validation/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/options/VisualTransformation.kt +++ b/kmm-form-validation/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/options/VisualTransformation.kt @@ -29,23 +29,4 @@ interface VisualTransformation { } fun restore(text: String): String -} - -/** - * The Visual Filter can be used for password Input Field. - * - * Note that this visual filter only works for ASCII characters. - * - * @param mask The mask character used instead of original text. - */ -data class PasswordVisualTransformation(val mask: Char = '\u2022') : VisualTransformation { - constructor() : this(mask = '\u2022') - - override fun filter(text: String): TransformedText { - return TransformedText(mask.toString().repeat(text.length), OffsetMapping.Identity) - } - - override fun restore(text: String): String { - return text - } } \ No newline at end of file diff --git a/sample/androidSample/src/main/java/ru/mobileup/kmm_form_validation/android_sample/ui/widgets/PasswordTextField.kt b/sample/androidSample/src/main/java/ru/mobileup/kmm_form_validation/android_sample/ui/widgets/PasswordTextField.kt index c63394b..3584107 100644 --- a/sample/androidSample/src/main/java/ru/mobileup/kmm_form_validation/android_sample/ui/widgets/PasswordTextField.kt +++ b/sample/androidSample/src/main/java/ru/mobileup/kmm_form_validation/android_sample/ui/widgets/PasswordTextField.kt @@ -15,16 +15,17 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.input.PasswordVisualTransformation import dev.icerock.moko.resources.compose.localized import kotlinx.coroutines.flow.collectLatest import ru.mobileup.kmm_form_validation.android_sample.R +import ru.mobileup.kmm_form_validation.control.InputControl import ru.mobileup.kmm_form_validation.toCompose @OptIn(ExperimentalFoundationApi::class) @Composable fun PasswordTextField( - inputControl: ru.mobileup.kmm_form_validation.control.InputControl, + inputControl: InputControl, label: String, modifier: Modifier = Modifier ) { @@ -64,9 +65,9 @@ fun PasswordTextField( isError = error != null, onValueChange = inputControl::onTextChanged, visualTransformation = if (passwordVisibility) { - VisualTransformation.None - } else { inputControl.visualTransformation.toCompose() + } else { + PasswordVisualTransformation() }, trailingIcon = { val image = if (passwordVisibility) { diff --git a/sample/androidSample/src/main/java/ru/mobileup/kmm_form_validation/android_sample/ui/widgets/TextField.kt b/sample/androidSample/src/main/java/ru/mobileup/kmm_form_validation/android_sample/ui/widgets/TextField.kt index 5545e0d..874713f 100644 --- a/sample/androidSample/src/main/java/ru/mobileup/kmm_form_validation/android_sample/ui/widgets/TextField.kt +++ b/sample/androidSample/src/main/java/ru/mobileup/kmm_form_validation/android_sample/ui/widgets/TextField.kt @@ -14,12 +14,13 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import dev.icerock.moko.resources.compose.localized import kotlinx.coroutines.flow.collectLatest +import ru.mobileup.kmm_form_validation.control.InputControl import ru.mobileup.kmm_form_validation.toCompose @OptIn(ExperimentalFoundationApi::class) @Composable fun TextField( - inputControl: ru.mobileup.kmm_form_validation.control.InputControl, + inputControl: InputControl, label: String, modifier: Modifier = Modifier ) { diff --git a/sample/iosSample/iosSample.xcodeproj/project.pbxproj b/sample/iosSample/iosSample.xcodeproj/project.pbxproj index 06c8c03..bd7cb62 100644 --- a/sample/iosSample/iosSample.xcodeproj/project.pbxproj +++ b/sample/iosSample/iosSample.xcodeproj/project.pbxproj @@ -7,29 +7,27 @@ objects = { /* Begin PBXBuildFile section */ + E521689129955B3F00F366EF /* ScrolledFocusField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E521689029955B3F00F366EF /* ScrolledFocusField.swift */; }; EC7C1FEA2987B251001DCE8B /* iosSampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7C1FE92987B251001DCE8B /* iosSampleApp.swift */; }; EC7C1FEC2987B251001DCE8B /* TextFieldWithControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7C1FEB2987B251001DCE8B /* TextFieldWithControl.swift */; }; EC7C1FEE2987B252001DCE8B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EC7C1FED2987B252001DCE8B /* Assets.xcassets */; }; EC7C1FF12987B252001DCE8B /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EC7C1FF02987B252001DCE8B /* Preview Assets.xcassets */; }; EC7C20172987F483001DCE8B /* UnsafeObservableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7C20162987F483001DCE8B /* UnsafeObservableState.swift */; }; - EC7C201B298A6A67001DCE8B /* VisualFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7C201A298A6A66001DCE8B /* VisualFormatter.swift */; }; EC7C201E298A6AD1001DCE8B /* VisualExtentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7C201D298A6AD1001DCE8B /* VisualExtentions.swift */; }; - EC7C2020298A6B76001DCE8B /* SecureTextFieldWithControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7C201F298A6B76001DCE8B /* SecureTextFieldWithControl.swift */; }; EC7C2023298A710B001DCE8B /* ToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7C2022298A710B001DCE8B /* ToggleView.swift */; }; EC7C2025298A7883001DCE8B /* SubmitButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7C2024298A7883001DCE8B /* SubmitButtonView.swift */; }; ECB57B3B298CFC4E00AE547C /* FormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECB57B3A298CFC4E00AE547C /* FormView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + E521689029955B3F00F366EF /* ScrolledFocusField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrolledFocusField.swift; sourceTree = ""; }; EC7C1FE62987B251001DCE8B /* iosSample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosSample.app; sourceTree = BUILT_PRODUCTS_DIR; }; EC7C1FE92987B251001DCE8B /* iosSampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iosSampleApp.swift; sourceTree = ""; }; EC7C1FEB2987B251001DCE8B /* TextFieldWithControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldWithControl.swift; sourceTree = ""; }; EC7C1FED2987B252001DCE8B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; EC7C1FF02987B252001DCE8B /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; EC7C20162987F483001DCE8B /* UnsafeObservableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsafeObservableState.swift; sourceTree = ""; }; - EC7C201A298A6A66001DCE8B /* VisualFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualFormatter.swift; sourceTree = ""; }; EC7C201D298A6AD1001DCE8B /* VisualExtentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualExtentions.swift; sourceTree = ""; }; - EC7C201F298A6B76001DCE8B /* SecureTextFieldWithControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureTextFieldWithControl.swift; sourceTree = ""; }; EC7C2022298A710B001DCE8B /* ToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleView.swift; sourceTree = ""; }; EC7C2024298A7883001DCE8B /* SubmitButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmitButtonView.swift; sourceTree = ""; }; ECB57B3A298CFC4E00AE547C /* FormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormView.swift; sourceTree = ""; }; @@ -46,6 +44,38 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + E521688F29955B0E00F366EF /* ViewModifiers */ = { + isa = PBXGroup; + children = ( + E521689029955B3F00F366EF /* ScrolledFocusField.swift */, + ); + path = ViewModifiers; + sourceTree = ""; + }; + E521689429955E2100F366EF /* Extensions */ = { + isa = PBXGroup; + children = ( + EC7C201D298A6AD1001DCE8B /* VisualExtentions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + E521689529955E5600F366EF /* Application */ = { + isa = PBXGroup; + children = ( + EC7C1FE92987B251001DCE8B /* iosSampleApp.swift */, + ); + path = Application; + sourceTree = ""; + }; + E521689629955E6800F366EF /* Resources */ = { + isa = PBXGroup; + children = ( + EC7C1FED2987B252001DCE8B /* Assets.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; EC7C1FDD2987B251001DCE8B = { isa = PBXGroup; children = ( @@ -66,10 +96,9 @@ isa = PBXGroup; children = ( EC7C2021298A70E7001DCE8B /* View */, - EC7C201C298A6AB4001DCE8B /* Input */, EC7C20152987F458001DCE8B /* State */, - EC7C1FE92987B251001DCE8B /* iosSampleApp.swift */, - EC7C1FED2987B252001DCE8B /* Assets.xcassets */, + E521689529955E5600F366EF /* Application */, + E521689629955E6800F366EF /* Resources */, EC7C1FEF2987B252001DCE8B /* Preview Content */, ); path = iosSample; @@ -91,19 +120,11 @@ path = State; sourceTree = ""; }; - EC7C201C298A6AB4001DCE8B /* Input */ = { - isa = PBXGroup; - children = ( - EC7C201A298A6A66001DCE8B /* VisualFormatter.swift */, - EC7C201D298A6AD1001DCE8B /* VisualExtentions.swift */, - ); - path = Input; - sourceTree = ""; - }; EC7C2021298A70E7001DCE8B /* View */ = { isa = PBXGroup; children = ( - EC7C201F298A6B76001DCE8B /* SecureTextFieldWithControl.swift */, + E521689429955E2100F366EF /* Extensions */, + E521688F29955B0E00F366EF /* ViewModifiers */, EC7C1FEB2987B251001DCE8B /* TextFieldWithControl.swift */, EC7C2022298A710B001DCE8B /* ToggleView.swift */, EC7C2024298A7883001DCE8B /* SubmitButtonView.swift */, @@ -204,13 +225,12 @@ buildActionMask = 2147483647; files = ( EC7C2023298A710B001DCE8B /* ToggleView.swift in Sources */, - EC7C201B298A6A67001DCE8B /* VisualFormatter.swift in Sources */, EC7C1FEC2987B251001DCE8B /* TextFieldWithControl.swift in Sources */, EC7C2025298A7883001DCE8B /* SubmitButtonView.swift in Sources */, EC7C20172987F483001DCE8B /* UnsafeObservableState.swift in Sources */, - EC7C2020298A6B76001DCE8B /* SecureTextFieldWithControl.swift in Sources */, ECB57B3B298CFC4E00AE547C /* FormView.swift in Sources */, EC7C201E298A6AD1001DCE8B /* VisualExtentions.swift in Sources */, + E521689129955B3F00F366EF /* ScrolledFocusField.swift in Sources */, EC7C1FEA2987B251001DCE8B /* iosSampleApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -340,6 +360,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"iosSample/Preview Content\""; + DEVELOPMENT_TEAM = 4778UMCE49; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../sharedSample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; GENERATE_INFOPLIST_FILE = YES; @@ -374,6 +395,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"iosSample/Preview Content\""; + DEVELOPMENT_TEAM = 4778UMCE49; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../sharedSample/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; GENERATE_INFOPLIST_FILE = YES; diff --git a/sample/iosSample/iosSample/iosSampleApp.swift b/sample/iosSample/iosSample/Application/iosSampleApp.swift similarity index 87% rename from sample/iosSample/iosSample/iosSampleApp.swift rename to sample/iosSample/iosSample/Application/iosSampleApp.swift index fb7cf94..9643c92 100644 --- a/sample/iosSample/iosSample/iosSampleApp.swift +++ b/sample/iosSample/iosSample/Application/iosSampleApp.swift @@ -3,9 +3,7 @@ import sharedSample @main struct iosSampleApp: App { - - @StateObject - private var rootHolder = RootHolder() + @StateObject private var rootHolder = RootHolder() var body: some Scene { WindowGroup { @@ -16,7 +14,7 @@ struct iosSampleApp: App { } } -private class RootHolder : ObservableObject { +private final class RootHolder : ObservableObject { let lifecycle: LifecycleRegistry let formComponent: FormComponent diff --git a/sample/iosSample/iosSample/Input/VisualExtentions.swift b/sample/iosSample/iosSample/Input/VisualExtentions.swift deleted file mode 100644 index 7d370a9..0000000 --- a/sample/iosSample/iosSample/Input/VisualExtentions.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -import sharedSample -import SwiftUI - -extension KeyboardType { - func toUI() -> UIKeyboardType { - let keyboardType: UIKeyboardType - switch self { - case .text: keyboardType = .default - case .ascii: keyboardType = .asciiCapable - case .number: keyboardType = .numberPad - case .phone: keyboardType = .phonePad - case .uri: keyboardType = .URL - case .email: keyboardType = .emailAddress - case .password: keyboardType = .default - case .numberpassword: keyboardType = .numberPad - default: keyboardType = .default - } - return keyboardType - } -} - -extension ImeAction { - func toUI() -> SubmitLabel { - let label: SubmitLabel - switch self { - case .default_: label = .done - case .done: label = .done - case .go: label = .go - case .search: label = .search - case .send: label = .send - case .previous: label = .route - case .next: label = .next - default: label = .done - } - return label - } -} - -extension KeyboardCapitalization { - func toUI() -> TextInputAutocapitalization{ - let capitalization: TextInputAutocapitalization - switch self { - case .none: capitalization = .never - case .characters: capitalization = .characters - case .sentences: capitalization = .sentences - case .words: capitalization = .words - default: capitalization = .never - } - return capitalization - } -} - -extension SubmitButtonState { - func toUI() -> Color { - let color: Color - switch self { - case SubmitButtonState.invalid: color = Color.red - case SubmitButtonState.valid: color = Color.green - default: color = Color.red - } - return color - } -} diff --git a/sample/iosSample/iosSample/Input/VisualFormatter.swift b/sample/iosSample/iosSample/Input/VisualFormatter.swift deleted file mode 100644 index 8f4d115..0000000 --- a/sample/iosSample/iosSample/Input/VisualFormatter.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation -import sharedSample - -class VisualFormatter: Formatter{ - let visualTransformation: VisualTransformation - - init(_ visualTransformation: VisualTransformation) { - self.visualTransformation = visualTransformation - super.init() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func string(for obj: Any?) -> String? { - let string = obj as? String - - if let string = string { - return visualTransformation.filter(text: string).text - } else { - return nil - } - } - - override func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer?, for string: String, errorDescription error: AutoreleasingUnsafeMutablePointer?) -> Bool { - let result = visualTransformation.restore(text: string) - obj?.pointee = result as AnyObject - return true - } -} diff --git a/sample/iosSample/iosSample/Assets.xcassets/AccentColor.colorset/Contents.json b/sample/iosSample/iosSample/Resources/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from sample/iosSample/iosSample/Assets.xcassets/AccentColor.colorset/Contents.json rename to sample/iosSample/iosSample/Resources/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/sample/iosSample/iosSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/sample/iosSample/iosSample/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from sample/iosSample/iosSample/Assets.xcassets/AppIcon.appiconset/Contents.json rename to sample/iosSample/iosSample/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/sample/iosSample/iosSample/Assets.xcassets/Contents.json b/sample/iosSample/iosSample/Resources/Assets.xcassets/Contents.json similarity index 100% rename from sample/iosSample/iosSample/Assets.xcassets/Contents.json rename to sample/iosSample/iosSample/Resources/Assets.xcassets/Contents.json diff --git a/sample/iosSample/iosSample/State/UnsafeObservableState.swift b/sample/iosSample/iosSample/State/UnsafeObservableState.swift index b347ba2..7e9797c 100644 --- a/sample/iosSample/iosSample/State/UnsafeObservableState.swift +++ b/sample/iosSample/iosSample/State/UnsafeObservableState.swift @@ -1,19 +1,21 @@ import Foundation import sharedSample -public class UnsafeObservableState: ObservableObject { - - @Published - var value: T? +public final class UnsafeObservableState: ObservableObject { + @Published var value: T? private var cancelable: Cancelable? = nil init(_ state: Kotlinx_coroutines_coreStateFlow) { self.value = state.value as? T - cancelable = FlowWrapper(flow: state).collect(consumer: { value in + cancelable = FlowWrapper(flow: state).collect { value in self.value = value - }) + } + } + + func reemitValue() { + value = value } deinit { diff --git a/sample/iosSample/iosSample/View/Extensions/VisualExtentions.swift b/sample/iosSample/iosSample/View/Extensions/VisualExtentions.swift new file mode 100644 index 0000000..2185c97 --- /dev/null +++ b/sample/iosSample/iosSample/View/Extensions/VisualExtentions.swift @@ -0,0 +1,76 @@ +import sharedSample +import SwiftUI + +extension KeyboardType { + func toUI() -> UIKeyboardType { + switch self { + case .ascii: + return .asciiCapable + case .number: + return .numberPad + case .phone: + return .phonePad + case .uri: + return .URL + case .email: + return .emailAddress + case .numberpassword: + return .numberPad + case .text, .password: + return .default + default: + return .default + } + } +} + +extension ImeAction { + func toUI() -> SubmitLabel { + switch self { + case .done: + return .done + case .go: + return .go + case .search: + return .search + case .send: + return .send + case .previous: + return .route + case .next: + return .next + default: + return .done + } + } +} + +extension KeyboardCapitalization { + func toUI() -> TextInputAutocapitalization { + switch self { + case .characters: + return .characters + case .sentences: + return .sentences + case .words: + return .words + case .none: + return .never + default: + return .never + } + } +} + +extension SubmitButtonState { + func toUI() -> Color { + switch self { + case SubmitButtonState.invalid: + return .red + case SubmitButtonState.valid: + return .green + default: + return .red + } + } +} diff --git a/sample/iosSample/iosSample/View/FormView.swift b/sample/iosSample/iosSample/View/FormView.swift index 119d5e1..c5cf74e 100644 --- a/sample/iosSample/iosSample/View/FormView.swift +++ b/sample/iosSample/iosSample/View/FormView.swift @@ -2,68 +2,131 @@ import SwiftUI import sharedSample struct FormView: View { - - let formComponent: FormComponent + private enum Field: Int { + case name = 0 + case email = 1 + case phone = 2 + case password = 3 + case passwordConfirmation = 4 + } + + @ObservedObject private var submitButtonState: UnsafeObservableState + @ObservedObject private var isValid: UnsafeObservableState - @ObservedObject - var submitButtonState: UnsafeObservableState + @FocusState private var focus: Int? - @ObservedObject - var valid: UnsafeObservableState + private let formComponent: FormComponent init(formComponent: FormComponent) { self.formComponent = formComponent self.submitButtonState = UnsafeObservableState(formComponent.submitButtonState) - self.valid = UnsafeObservableState(formComponent.valid) + self.isValid = UnsafeObservableState(formComponent.valid) } var body: some View { - VStack{ + ScrollView(showsIndicators: false) { + ScrollViewReader { proxy in + VStack { + getTitleView() + getTextFiledStackView(proxy: proxy) + ToggleView( + checkControl: formComponent.termsCheckBox, + label: MR.strings().terms_hint.desc().localized() + ) + .id(5) + SubmitButton( + label: MR.strings().submit_button.desc().localized(), + buttonState: submitButtonState.value!, + action: formComponent.onSubmitClicked + ) + + if(isValid.value?.boolValue ?? false) { + Text(MR.strings().success_message.desc().localized()) + .padding(8) + .foregroundColor(submitButtonState.value?.toUI()) + } + + Color.clear.padding(.bottom, 70) + } + .padding() + } + } + } + + private func getTitleView() -> some View { + HStack { + Text("Default Form") + .font(.largeTitle) + .fontWeight(.bold) + .foregroundColor(.gray) + Spacer() + } + .padding(.vertical, 20) + } + + private func getTextFiledStackView(proxy: ScrollViewProxy) -> some View { + VStack { TextFieldWithControl( inputControl: formComponent.nameInput, hint: MR.strings().name_hint.desc().localized() ) - + .scrolledFocus( + focus: _focus, + id: Field.name.rawValue, + nextId: Field.email.rawValue, + proxy: proxy + ) + .id(Field.name.rawValue) TextFieldWithControl( inputControl: formComponent.emailInput, hint: MR.strings().email_hint.desc().localized() ) - + .scrolledFocus( + focus: _focus, + id: Field.email.rawValue, + nextId: Field.phone.rawValue, + proxy: proxy + ) + .id(Field.email.rawValue) TextFieldWithControl( inputControl: formComponent.phoneInput, hint: MR.strings().phone_hint.desc().localized() ) - - SecureTextFieldWithControl( + .scrolledFocus( + focus: _focus, + id: Field.phone.rawValue, + nextId: Field.password.rawValue, + proxy: proxy + ) + .id(Field.phone.rawValue) + TextFieldWithControl( inputControl: formComponent.passwordInput, - hint: MR.strings().password_hint.desc().localized() + hint: MR.strings().password_hint.desc().localized(), + isSecure: true ) - - SecureTextFieldWithControl( - inputControl: formComponent.confirmPasswordInput, - hint: MR.strings().confirm_password_hint.desc().localized() + .scrolledFocus( + focus: _focus, + id: Field.password.rawValue, + nextId: Field.passwordConfirmation.rawValue, + proxy: proxy ) - - ToggleView( - checkControl: formComponent.termsCheckBox, - label: MR.strings().terms_hint.desc().localized() + .id(Field.password.rawValue) + TextFieldWithControl( + inputControl: formComponent.confirmPasswordInput, + hint: MR.strings().confirm_password_hint.desc().localized(), + isSecure: true ) - - SubmitButtonView( - buttonState: submitButtonState.value!, - label: MR.strings().submit_button.desc().localized(), - action: formComponent.onSubmitClicked + .scrolledFocus( + focus: _focus, + id: Field.passwordConfirmation.rawValue, + nextId: 5, + proxy: proxy ) - - if(valid.value?.boolValue ?? false) { - Text(MR.strings().success_message.desc().localized()) - .padding(8) - } + .id(Field.passwordConfirmation.rawValue) } } } - struct FormView_Previews: PreviewProvider { static var previews: some View { FormView(formComponent: FakeFormComponent()) diff --git a/sample/iosSample/iosSample/View/SecureTextFieldWithControl.swift b/sample/iosSample/iosSample/View/SecureTextFieldWithControl.swift deleted file mode 100644 index 35a00da..0000000 --- a/sample/iosSample/iosSample/View/SecureTextFieldWithControl.swift +++ /dev/null @@ -1,70 +0,0 @@ -import SwiftUI -import Combine -import sharedSample - -struct SecureTextFieldWithControl: View { - - private let hint: String - - private let inputControl: InputControl - - @ObservedObject - private var text: UnsafeObservableState - - @ObservedObject - private var error: UnsafeObservableState - - @ObservedObject - private var hasFocus: UnsafeObservableState - - @ObservedObject - private var enabled: UnsafeObservableState - - @State - private var keyboardOptions: KeyboardOptions - - @FocusState - private var isFocused: Bool - - init(inputControl: InputControl, hint: String) { - self.hint = hint - self.inputControl = inputControl - self.keyboardOptions = inputControl.keyboardOptions - self.text = UnsafeObservableState(inputControl.text) - self.error = UnsafeObservableState(inputControl.error) - self.hasFocus = UnsafeObservableState(inputControl.hasFocus) - self.enabled = UnsafeObservableState(inputControl.enabled) - } - - var body: some View { - VStack { - SecureField( - text: Binding { - String(text.value ?? "") - } set: { value in - inputControl.onTextChanged(text:value) - }, - prompt: Text(hint), - label: { - Text("123") - } - ) - .textFieldStyle(.roundedBorder) - .focused($isFocused) - .onChange(of: isFocused) { newValue in - inputControl.onFocusChanged(hasFocus: newValue) - } - .disabled(!(enabled.value?.boolValue ?? false)) - .keyboardType(keyboardOptions.keyboardType.toUI()) - .submitLabel(keyboardOptions.imeAction.toUI()) - .textInputAutocapitalization(keyboardOptions.capitalization.toUI()) - .autocorrectionDisabled(!keyboardOptions.autoCorrect) - - if let error = error.value { - Text(error.localized()) - .foregroundColor(.red) - } - } - .padding(4) - } -} diff --git a/sample/iosSample/iosSample/View/SubmitButtonView.swift b/sample/iosSample/iosSample/View/SubmitButtonView.swift index 0ba0dc0..09a939c 100644 --- a/sample/iosSample/iosSample/View/SubmitButtonView.swift +++ b/sample/iosSample/iosSample/View/SubmitButtonView.swift @@ -1,26 +1,22 @@ import SwiftUI import sharedSample -struct SubmitButtonView: View { - - let buttonState: SubmitButtonState +struct SubmitButton: View { let label: String + let buttonState: SubmitButtonState let action: () -> Void - init(buttonState: SubmitButtonState, label: String, action: @escaping () -> Void) { - self.label = label - self.buttonState = buttonState - self.action = action - } - var body: some View { Button( action: action, label: { - Text(label).padding(8) + Text(label) + .padding(.vertical, 8) + .padding(.horizontal, 20) } ) .buttonStyle(BorderedButtonStyle()) .foregroundColor(buttonState.toUI()) + .cornerRadius(20) } } diff --git a/sample/iosSample/iosSample/View/TextFieldWithControl.swift b/sample/iosSample/iosSample/View/TextFieldWithControl.swift index 8c4c1ad..6339de9 100644 --- a/sample/iosSample/iosSample/View/TextFieldWithControl.swift +++ b/sample/iosSample/iosSample/View/TextFieldWithControl.swift @@ -1,68 +1,95 @@ import SwiftUI -import Combine import sharedSample struct TextFieldWithControl: View { + @ObservedObject private var text: UnsafeObservableState + @ObservedObject private var error: UnsafeObservableState + @ObservedObject private var hasFocus: UnsafeObservableState + @ObservedObject private var isEnabled: UnsafeObservableState - private let hint: String + @FocusState private var isFocused: Bool + private let keyboardOptions: KeyboardOptions private let inputControl: InputControl - - @ObservedObject - private var text: UnsafeObservableState - - @ObservedObject - private var error: UnsafeObservableState - - @ObservedObject - private var hasFocus: UnsafeObservableState - - @ObservedObject - private var enabled: UnsafeObservableState - - @State - private var keyboardOptions: KeyboardOptions - - @FocusState - private var isFocused: Bool + private let hint: String + private let isSecure: Bool - init(inputControl: InputControl, hint: String) { + init(inputControl: InputControl, hint: String, isSecure: Bool = false) { self.hint = hint self.inputControl = inputControl + self.isSecure = isSecure self.keyboardOptions = inputControl.keyboardOptions self.text = UnsafeObservableState(inputControl.text) self.error = UnsafeObservableState(inputControl.error) self.hasFocus = UnsafeObservableState(inputControl.hasFocus) - self.enabled = UnsafeObservableState(inputControl.enabled) + self.isEnabled = UnsafeObservableState(inputControl.enabled) } var body: some View { VStack { - TextField( - hint, - value: Binding { + TextFieldView( + text: Binding { String(text.value ?? "") } set: { value in - inputControl.onTextChanged(text:value) + inputControl.onTextChanged(text: value) + text.reemitValue() }, - formatter: VisualFormatter(inputControl.visualTransformation) + isSecure: isSecure, + isError: error.value == nil, + hint: hint ) - .textFieldStyle(.roundedBorder) - .focused($isFocused) - .onChange(of: isFocused) { newValue in - inputControl.onFocusChanged(hasFocus: newValue) - } - .disabled(!(enabled.value?.boolValue ?? false)) + .disabled(!(isEnabled.value?.boolValue ?? false)) .keyboardType(keyboardOptions.keyboardType.toUI()) .submitLabel(keyboardOptions.imeAction.toUI()) .textInputAutocapitalization(keyboardOptions.capitalization.toUI()) .autocorrectionDisabled(!keyboardOptions.autoCorrect) + .focused($isFocused) + .onChange(of: isFocused) { newValue in + inputControl.onFocusChanged(hasFocus: newValue) + } + .onChange(of: hasFocus.value?.boolValue ?? false) { newValue in + isFocused = newValue + } if let error = error.value { - Text(error.localized()) - .foregroundColor(.red) + HStack { + Text(error.localized()) + .foregroundColor(.red) + Spacer() + } + } + } + .padding(.vertical, 10) + } + + private struct TextFieldView: View { + @Binding var text: String + + let isSecure: Bool + let isError: Bool + let hint: String + + var body: some View { + createTextField() + .padding(10) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(isError ? Color.gray : Color.red, lineWidth: 2) + } + } + + private func createTextField() -> some View { + if isSecure { + SecureField( + text: $text, + prompt: Text(hint), + label: { + Text("") + } + ) + } else { + TextField(hint, text: $text) } } - .padding(4) } } diff --git a/sample/iosSample/iosSample/View/ToggleView.swift b/sample/iosSample/iosSample/View/ToggleView.swift index 5fb91af..c5a8549 100644 --- a/sample/iosSample/iosSample/View/ToggleView.swift +++ b/sample/iosSample/iosSample/View/ToggleView.swift @@ -2,21 +2,16 @@ import SwiftUI import sharedSample struct ToggleView: View { + @ObservedObject private var isChecked: UnsafeObservableState + @ObservedObject private var error: UnsafeObservableState - let checkControl: CheckControl - - let label: String - - @ObservedObject - var checked: UnsafeObservableState - - @ObservedObject - private var error: UnsafeObservableState + private let checkControl: CheckControl + private let label: String init(checkControl: CheckControl, label: String) { self.label = label self.checkControl = checkControl - checked = UnsafeObservableState(checkControl.checked) + isChecked = UnsafeObservableState(checkControl.checked) error = UnsafeObservableState(checkControl.error) } @@ -24,7 +19,7 @@ struct ToggleView: View { VStack { Toggle( isOn: Binding( - get: { checked.value?.boolValue ?? false }, + get: { isChecked.value?.boolValue ?? false }, set: checkControl.onCheckedChanged ), label: { @@ -33,8 +28,11 @@ struct ToggleView: View { ) if let error = error.value { - Text(error.localized()) - .foregroundColor(.red) + HStack { + Text(error.localized()) + .foregroundColor(.red) + Spacer() + } } } .padding(4) diff --git a/sample/iosSample/iosSample/View/ViewModifiers/ScrolledFocusField.swift b/sample/iosSample/iosSample/View/ViewModifiers/ScrolledFocusField.swift new file mode 100644 index 0000000..9005443 --- /dev/null +++ b/sample/iosSample/iosSample/View/ViewModifiers/ScrolledFocusField.swift @@ -0,0 +1,61 @@ +// +// ScrolledFocusField.swift +// iosSample +// +// Created by Чаусов Николай on 09.02.2023. +// + +import SwiftUI + +private struct ScrolledFocusField: ViewModifier { + + @FocusState var focus: Int? + + let id: Int + let nextId: Int + let proxy: ScrollViewProxy + let anchor: UnitPoint + let nextAnchor: UnitPoint + + func body(content: Content) -> some View { + content + .focused($focus, equals: id) + .onTapGesture { + scrollToRowWithAnimation(proxy: proxy, id, anchor: anchor) + } + .onSubmit { + scrollToRowWithAnimation(proxy: proxy, nextId, anchor: nextAnchor) + focus = nextId + } + } +} + +private extension ViewModifier { + + func scrollToRowWithAnimation(proxy: ScrollViewProxy, _ row: Int, anchor: UnitPoint = .center) { + withAnimation { + proxy.scrollTo(row, anchor: anchor) + } + } +} + +extension View { + + func scrolledFocus( + focus: FocusState, + id: Int, + nextId: Int, + proxy: ScrollViewProxy, + anchor: UnitPoint = .center, + nextAnchor: UnitPoint = .center + ) -> some View { + modifier(ScrolledFocusField( + focus: focus, + id: id, + nextId: nextId, + proxy: proxy, + anchor: anchor, + nextAnchor: nextAnchor + )) + } +} diff --git a/sample/sharedSample/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/sharedsample/ui/RealFormComponent.kt b/sample/sharedSample/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/sharedsample/ui/RealFormComponent.kt index ad9df6e..12479d4 100644 --- a/sample/sharedSample/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/sharedsample/ui/RealFormComponent.kt +++ b/sample/sharedSample/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/sharedsample/ui/RealFormComponent.kt @@ -6,7 +6,10 @@ import dev.icerock.moko.resources.desc.StringDesc import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import ru.mobileup.kmm_form_validation.options.* +import ru.mobileup.kmm_form_validation.options.ImeAction +import ru.mobileup.kmm_form_validation.options.KeyboardCapitalization +import ru.mobileup.kmm_form_validation.options.KeyboardOptions +import ru.mobileup.kmm_form_validation.options.KeyboardType import ru.mobileup.kmm_form_validation.sharedsample.MR import ru.mobileup.kmm_form_validation.sharedsample.utils.* import ru.mobileup.kmm_form_validation.validation.control.* @@ -57,7 +60,7 @@ class RealFormComponent( keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next ), - textTransformation = { it.replace(Regex("[^1234567890(-)+]"), "") }, + textTransformation = { text -> text.filter { it.isDigit() } }, visualTransformation = RussianPhoneNumberVisualTransformation ) @@ -65,16 +68,14 @@ class RealFormComponent( keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, imeAction = ImeAction.Next - ), - visualTransformation = PasswordVisualTransformation() + ) ) override val confirmPasswordInput = InputControl( keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done - ), - visualTransformation = PasswordVisualTransformation() + ) ) override val termsCheckBox = CheckControl() diff --git a/sample/sharedSample/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/sharedsample/utils/FlowWrapper.kt b/sample/sharedSample/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/sharedsample/utils/FlowWrapper.kt index 226dabf..70ad29f 100644 --- a/sample/sharedSample/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/sharedsample/utils/FlowWrapper.kt +++ b/sample/sharedSample/src/commonMain/kotlin/ru/mobileup/kmm_form_validation/sharedsample/utils/FlowWrapper.kt @@ -4,7 +4,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -29,13 +28,4 @@ open class FlowWrapper( } } } -} - -class MutableStateFlowWrapper( - private val stateFlow: MutableStateFlow -) : FlowWrapper(stateFlow) { - - fun update(value: T) { - stateFlow.value = value - } } \ No newline at end of file