Skip to content

Commit

Permalink
Merge pull request #3 from MrAsterisco/1-multiple-combopicker-in-the-…
Browse files Browse the repository at this point in the history
…same-view-conflict-with-each-other

Fix multiple pickers on iOS, add value formatter
  • Loading branch information
MrAsterisco authored Jul 3, 2022
2 parents aed892c + 08eda0b commit b502429
Show file tree
Hide file tree
Showing 11 changed files with 241 additions and 63 deletions.
7 changes: 7 additions & 0 deletions .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
FA61501A286CDDCF00ADEE20 /* ExampleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA614FF6286CDBAC00ADEE20 /* ExampleModel.swift */; };
FA61501B286CDDD300ADEE20 /* ExampleModel+StaticData.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA614FF8286CDBB100ADEE20 /* ExampleModel+StaticData.swift */; };
FA61501D286CDE1700ADEE20 /* ComboPicker in Frameworks */ = {isa = PBXBuildFile; productRef = FA61501C286CDE1700ADEE20 /* ComboPicker */; };
FADCE652287191B1004BC0CB /* ExampleModelFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADCE651287191B1004BC0CB /* ExampleModelFormatter.swift */; };
FADCE65328719284004BC0CB /* ExampleModelFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADCE651287191B1004BC0CB /* ExampleModelFormatter.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -63,6 +65,7 @@
FA615009286CDD6900ADEE20 /* ComboPickerWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComboPickerWatchApp.swift; sourceTree = "<group>"; };
FA61500D286CDD6A00ADEE20 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
FA615010286CDD6A00ADEE20 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
FADCE651287191B1004BC0CB /* ExampleModelFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleModelFormatter.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -140,6 +143,7 @@
children = (
FA614FF6286CDBAC00ADEE20 /* ExampleModel.swift */,
FA614FF8286CDBB100ADEE20 /* ExampleModel+StaticData.swift */,
FADCE651287191B1004BC0CB /* ExampleModelFormatter.swift */,
);
path = Data;
sourceTree = "<group>";
Expand Down Expand Up @@ -306,6 +310,7 @@
FA614FE9286CDB8F00ADEE20 /* ContentView.swift in Sources */,
FA614FE7286CDB8F00ADEE20 /* ComboPickerExampleApp.swift in Sources */,
FA614FF9286CDBB100ADEE20 /* ExampleModel+StaticData.swift in Sources */,
FADCE652287191B1004BC0CB /* ExampleModelFormatter.swift in Sources */,
FA614FF7286CDBAC00ADEE20 /* ExampleModel.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand All @@ -317,6 +322,7 @@
FA615019286CDDB900ADEE20 /* ContentView.swift in Sources */,
FA61500A286CDD6900ADEE20 /* ComboPickerWatchApp.swift in Sources */,
FA61501B286CDDD300ADEE20 /* ExampleModel+StaticData.swift in Sources */,
FADCE65328719284004BC0CB /* ExampleModelFormatter.swift in Sources */,
FA61501A286CDDCF00ADEE20 /* ExampleModel.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
20 changes: 18 additions & 2 deletions Examples/ComboPickerExample/ComboPickerExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,37 @@
import SwiftUI
import ComboPicker

#if os(watchOS) || os(tvOS)
typealias Stack = VStack
#else
typealias Stack = HStack
#endif

struct ContentView: View {
@State private var content = ExampleModel.data

@State private var selection = ExampleModel.data.first!.value
@State private var otherSelection = ExampleModel.data.last!.value

var body: some View {
VStack {
Stack {
ComboPicker(
title: "Pick a number",
manualTitle: "Custom...",
valueFormatter: ExampleModelFormatter(),
content: $content,
value: $selection
)
.keyboardType(.numberPad)

// Text("Selected value: \(selection)")
ComboPicker(
title: "Pick another number",
manualTitle: "Custom...",
valueFormatter: ExampleModelFormatter(),
content: $content,
value: $otherSelection
)
.keyboardType(.numberPad)
}
.padding()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import ComboPicker
import Foundation

public struct ExampleModel: ComboPickerModel {
public static func ==(lhs: ExampleModel, rhs: ExampleModel) -> Bool {
lhs.value == rhs.value
}

public let id = UUID()
public let value: Int

Expand All @@ -17,8 +21,4 @@ public struct ExampleModel: ComboPickerModel {
public var valueForManualInput: String? {
NumberFormatter().string(from: .init(value: value))
}

public var label: String {
"# \(NumberFormatter().string(from: .init(value: value)) ?? "")"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// ExampleModelFormatter.swift
// ComboPickerExample
//
// Created by Alessio Moiso on 03.07.22.
//

import Foundation
import ComboPicker

final class ExampleModelFormatter: ValueFormatterType {
func string(from value: ExampleModel) -> String {
"# \(NumberFormatter().string(from: .init(value: value.value)) ?? "")"
}
}
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ ComboPicker can display any type that conforms to the `ComboPickerModel` protoco

```swift
public struct ExampleModel: ComboPickerModel {
public static func ==(lhs: ExampleModel, rhs: ExampleModel) -> Bool {
lhs.value == rhs.value
}

public let id = UUID()
public let value: Int

Expand All @@ -37,15 +41,21 @@ public struct ExampleModel: ComboPickerModel {
public var valueForManualInput: String? {
NumberFormatter().string(from: .init(value: value))
}

// Value display label.
public var label: String {
"# \(NumberFormatter().string(from: .init(value: value)) ?? "")"
}
```

You also have to provide an implementation of `ValueFormatterType`, so that the `ComboPicker` knows how to
represent values in the `Picker`s. The following example illustrates a simple formatter for the model implemented above:

```swift
final class ExampleModelFormatter: ValueFormatterType {
func string(from value: ExampleModel) -> String {
"# \(NumberFormatter().string(from: .init(value: value.value)) ?? "")"
}
}
```

Once you have a collection of models, displaying them in the `CombPicker` is easy:
Once you have a collection of models and the formatter implementation, building a `ComboPicker` is easy:

```swift
@State private var content: [ExampleModel]
Expand All @@ -54,6 +64,7 @@ Once you have a collection of models, displaying them in the `CombPicker` is eas
ComboPicker(
title: "Pick a number",
manualTitle: "Custom...",
valueFormatter: ExampleModelFormatter(),
content: $content,
value: $selection
)
Expand All @@ -63,7 +74,7 @@ ComboPicker(
`ComboPicker` adapts to the platform to provide an easy and accessible experience regardless of the device.

### iOS & iPadOS
On iOS and iPadOS, the `ComboPicker` shows a one-line `Picker` that the user can scroll. If the user taps on it, a text field for manual input appears.
On iOS and iPadOS, the `ComboPicker` shows a one-line `UIPickerView` that the user can scroll. If the user taps on it, a text field for manual input appears.

![ComboPicker](images/iphone.gif)

Expand All @@ -73,6 +84,9 @@ If necessary, you can customize the keyboard type for the manual input field:
.keyboardType(.numberPad)
```

_Note: because of limitations of the SwiftUI `Picker` regarding the gestures handling, as well as the ability of showing and using multiple wheel pickers in the
same screen, `ComboPicker` is currently relying on a `UIViewRepresentable` implementation of a `UIPickerView`. You can read more about the current limitations [here](https://stackoverflow.com/questions/69122169/ios15-swiftui-wheelpicker-scrollable-outside-frame-and-clipped-area-destructin?noredirect=1&lq=1)._

### watchOS
On watchOS, the `ComboPicker`shows a normal `Picker` that the user can scroll using their fingers or the digital crown. If the user taps on it, a text field for manual input appears.

Expand Down
100 changes: 60 additions & 40 deletions Sources/ComboPicker/ComboPicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,58 @@ import SwiftUI
/// options, while still giving them the ability to input new ones.
/// You can create a combo picker with any value that conforms to `ComboPickerModel`.
///
/// The following code shows an example implementation of a `ComboPickerModel` that wraps an `Int`:
///
/// ```swift
/// public struct Model: ComboPickerModel {
/// public static func ==(lhs: Model, rhs: Model) -> Bool {
/// lhs.value == rhs.value
/// }
///
/// public let id = UUID()
/// public let value: Int
///
/// public init(value: Int) {
/// self.value = value
/// }
///
/// public init?(customValue: String) {
/// guard let doubleValue = NumberFormatter().number(from: customValue)?.intValue else { return nil }
/// self.init(value: doubleValue)
/// }
///
/// public var valueForManualInput: String? {
/// NumberFormatter().string(from: .init(value: value))
/// }
/// }
/// ```
///
/// # Predefined Values
/// The predefined values are displayed in a `Picker` on all platforms, except for macOS, where
/// the AppKit's `NSComboBox` is used.
/// AppKit's `NSComboBox` is used.
///
/// - warning: Please note that on tvOS, the amount of visible options in the Picker might be
/// - warning: On iOS and iPadOS, putting a picker below another one will cause the second picker
/// to take over all gestures and tap events of the first one. You can, however, fit two pickers on the same line.
/// - note: Please note that on tvOS, the amount of visible options in the Picker might be
/// limited, as all options are displayed inline.
///
/// # Formatting Values
/// The predefined values that will be displayed in the `ComboPicker` can be formatted by means of an
/// implementation of `ValueFormatterType`. You are required to provide one.
///
/// Here's an example implementation that uses a `NumberFormatter` to display an `Int` value.
///
/// ```swift
/// final class ModelFormatter: ValueFormatterType {
/// func string(from value: Model) -> String {
/// NumberFormatter().string(from: .init(value: value.value)) ?? ""
/// }
/// }
/// ```
///
/// - note: Because of the `NSComboBox` works on macOS, predefined values will not be formatted using
/// this formatter. Their implementation of `LosslessStringConvertible` will be used instead.
///
/// # Manual Input
/// When the user taps on the Picker, the component switches automatically to the manual input mode.
/// In this mode, the user is allowed to enter any value.
Expand All @@ -31,11 +76,12 @@ import SwiftUI
///
/// - note: On tvOS, there is no tap-interaction. The user can swipe down to access the text field directly.
/// - note: On macOS, the `NSComboBox` allows users to type directly inside the menu.
public struct ComboPicker<Model: ComboPickerModel>: View {
public struct ComboPicker<Model: ComboPickerModel, Formatter: ValueFormatterType>: View where Formatter.Value == Model {
private let title: String
private let manualTitle: String

fileprivate var keyboardType = KeyboardType.default
fileprivate var valueFormatter: Formatter

@Binding private var content: [Model]
@Binding private var value: Model.Value
Expand All @@ -45,14 +91,24 @@ public struct ComboPicker<Model: ComboPickerModel>: View {

@FocusState private var focus: ComboPickerMode?

/// Initialize a new `ComboPicker` using the passed values.
///
/// - parameters:
/// - title: A title that will be displayed when choosing from a predefined set of values.
/// - manualTitle: A title that will be displayed when using manual input.
/// - valueFormatter: A formatter implementation to represent values in the predefined set.
/// - content: The predefined set of values.
/// - value: The currently selected value.
public init(
title: String = "",
manualTitle: String = "",
valueFormatter: Formatter,
content: Binding<[Model]>,
value: Binding<Model.Value>
) {
self.title = title
self.manualTitle = manualTitle
self.valueFormatter = valueFormatter
self._content = content
self._value = value
}
Expand All @@ -65,6 +121,7 @@ public struct ComboPicker<Model: ComboPickerModel>: View {
title: title,
manualTitle: manualTitle,
keyboardType: keyboardType,
valueFormatter: valueFormatter,
content: $content,
value: $value,
focus: _focus,
Expand Down Expand Up @@ -118,40 +175,3 @@ private extension ComboPicker {
}
}
}

// MARK: - Preview
struct SwiftUIView_Previews: PreviewProvider {
@State static private var selection = "1"

struct TestModel: ComboPickerModel {
let id = UUID()
let value: String

init(value: String) {
self.value = value
}

init?(customValue: String) {
self.init(value: customValue)
}

var valueForManualInput: String? {
value
}

var label: String {
"\(value) kg"
}
}

static var previews: some View {
ComboPicker(
content: .constant(
(1..<100).map {
TestModel(value: "\($0)")
}
),
value: $selection
)
}
}
3 changes: 0 additions & 3 deletions Sources/ComboPicker/Protocols/ComboPickerModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ public protocol ComboPickerModel: Identifiable, Hashable {
/// - customValue: A custom value the user input.
init?(customValue: String)

/// Get the label to use to display this value.
var label: String { get }

/// Get the actual value.
var value: Value { get }

Expand Down
19 changes: 19 additions & 0 deletions Sources/ComboPicker/Protocols/ValueFormatterType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// File.swift
//
//
// Created by Alessio Moiso on 03.07.22.
//

import Foundation

/// A type that represents a generic formatter.
///
/// # Overview
/// You can provide an implementation of this protocol to format
/// the values you want to display in a `ComboPicker`.
public protocol ValueFormatterType {
associatedtype Value

func string(from value: Value) -> String
}
Loading

0 comments on commit b502429

Please sign in to comment.