gahntpo / 2waybindinguikit Goto Github PK
View Code? Open in Web Editor NEWDemonstrate how to create a 2 way binding in UIKit with Combine framework
Demonstrate how to create a 2 way binding in UIKit with Combine framework
Hey,
I am not sure if this is the right place to ask a question, but it is the only place I found, so here we go :)
At first, let me say, that I really like the implementation of 2 way bindings and used it extensively. It worked very well.
Unfortunately I ran into an issue recently where I got into an endless loop when updating a binding inside a sink method of the same binding. Maybe you have an idea of how to solve that ?
The scenario. I have a custom dropdown button which adopts the 2-way binding you proposed here. A dropdown button holds an array of DropdownOption<T>
to let the user select a value and a selected value of type DropdownOption<T>?
. T
is any value which is hashable and can be shown with a textual representation represented by the DropdownDescribable
protocol.
The 2-way binding is implemented like this:
// MARK: - Combine Binding
public extension DropdownButton where T: Hashable & DropdownDescribable {
func bind(to subject: CurrentValueSubject<DropdownOption<T>?, Never>, storeIn subscriptions: CancelBag) {
subject.sink { [weak self] value in
if value != self?._selectedOption {
self?._selectedOption = value
}
}.store(in: subscriptions)
self.selectedOption.sink { value in
if value != subject.value {
subject.send(value)
}
}.store(in: subscriptions)
}
}
The issue appears in the following case:
let cancelBag: CancelBag = CancelBag()
let selectedSize: CurrentValueSubject<DropdownOption<Int>?, Never> = .init(nil)
let button = DropdownButton<Int>()
button.bind(to: selectedSize, storeIn: cancelBag)
selectedSize.eraseToAnyPublisher()
.sink { value in
if value != nil {
selectedSize.value = nil
}
}
.store(in: cancelBag)
print("Send 1")
selectedSize.send(.init(value: 1))
print("Send 2")
selectedSize.send(.init(value: 2))
print("Send 3")
selectedSize.send(.init(value: 3))
print(selectedSize.value)
The original issue appears in a much more complicated context, but broken down it is something like: Whenever a value is selected and some constraint is met, here it would be value != nil
I want to set the selected size value to be nil.
What I see now is that the sink closure is executed before the value is set on the selectedSize
value. This leads to the checks in the 2-way binding, wether the current value of the CurrentValueSubject
equals the selected value on the button to fail and let the button sends a new value to the binding, even though the value will be set on the CurrentValueSubject
to the correct one milliseconds afterwards.
The full playground code would be this:
import Foundation
import UIKit
import Combine
/// A convenient wrapper around a set of AnyCancellable
public class CancelBag {
public var subscriptions = Set<AnyCancellable>()
public init() {}
}
extension CancelBag {
public func cancelAll() {
subscriptions.forEach { $0.cancel() }
}
}
extension AnyCancellable {
public func store(in cancelBag: CancelBag) {
cancelBag.subscriptions.insert(self)
}
}
extension Int: DropdownDescribable {
public var dropdownText: String {
return "\(self)"
}
}
/// A type that can be displayed by a dropdown menu
public protocol DropdownDescribable {
var dropdownText: String { get }
}
/// A type representation an instance of type `T` that can be selected by a dropdown menu
public struct DropdownOption<T: Hashable & DropdownDescribable>: Hashable {
public var key: Int
public var value: T
public var systemImage: String?
public var imageColor: UIColor?
public init(value: T, systemImage: String? = nil, imageColor: UIColor? = nil) {
self.value = value
self.key = value.hashValue
self.systemImage = systemImage
self.imageColor = imageColor
}
public static func == (lhs: DropdownOption, rhs: DropdownOption) -> Bool {
return lhs.key == rhs.key
&& lhs.systemImage == rhs.systemImage
&& lhs.imageColor == rhs.imageColor
}
public func hash(into hasher: inout Hasher) {
hasher.combine(key)
hasher.combine(systemImage)
hasher.combine(imageColor)
}
}
/// A button that present a dropdown menu to select a value from it
///
/// The values inside the dropdown menu represent instances of type `T`
public class DropdownButton<T: Hashable & DropdownDescribable>: UIButton {
// MARK: - TraitCollectionAwareUpdateView
// MARK: - Public properties
var selectedOption: AnyPublisher<DropdownOption<T>?, Never> {
return _cvs.eraseToAnyPublisher()
}
// MARK: - Private properties
private var _options: [DropdownOption<T>] = [] {
didSet {
self.updateDropdownMenu()
}
}
private var _cvs = CurrentValueSubject<DropdownOption<T>?, Never>(nil)
private var _selectedOption: DropdownOption<T>? = nil {
didSet {
self.updateDropdownMenu()
self.setTitle(_selectedOption?.value.dropdownText, for: .normal)
self._cvs.send(_selectedOption)
}
}
private var placeholder: String = ""
// MARK: - Initializers
override init(frame: CGRect) {
super.init(frame: frame)
configure()
updateDropdownMenu()
}
/// Create a new PSTDropdownbutton instance
///
/// - Parameters:
/// - options: Selectable options
/// - selectedOption: Optional already selected option
/// - placeholder: Placeholder text
/// - type: Button type
/// - Returns: A `PSTDropdownButton` instance
public convenience init(options: [DropdownOption<T>],
selectedOption: DropdownOption<T>? = nil,
placeholder: String,
type: UIButton.ButtonType = .system
) {
self.init(type: type)
self._options = options
self._selectedOption = selectedOption
self.placeholder = placeholder
configure()
updateDropdownMenu()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Overrides
public override func layoutSubviews() {
super.layoutSubviews()
if let imageView = self.imageView {
let buttonWidth = frame.width
let imageWidth = imageView.frame.width
imageEdgeInsets = UIEdgeInsets(top: 0, left: buttonWidth-imageWidth-(contentEdgeInsets.right*2), bottom: 0, right: -(buttonWidth-imageWidth-(contentEdgeInsets.right*2)))
titleEdgeInsets = UIEdgeInsets(top: 0, left: -imageWidth, bottom: 0, right: imageWidth)
}
}
// MARK: - Configuration
private func configure() {
self.setTitle(self._selectedOption?.value.dropdownText ?? self.placeholder, for: .normal)
self.setImage(UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate), for: .normal)
self.tintColor = .label
self.contentHorizontalAlignment = .left
self.role = .normal
self.showsMenuAsPrimaryAction = true
}
private func updateDropdownMenu() {
let children: [UIAction] = _options.map { option in
return .init(title: option.value.dropdownText,
image: option.systemImage != nil ? UIImage(systemName: option.systemImage!)?.withTintColor(option.imageColor ?? .label, renderingMode: .alwaysOriginal): nil,
state: option == self._selectedOption ? .on : .off,
handler: { [weak self] _ in
self?._selectedOption = option
})
}
self.menu = UIMenu(title: self.placeholder, children: children)
}
// MARK: - Action methods
/// Use this option to directly set a value of the dropdown menu
///
/// - Parameter value: Value to be set as `selected`
func setSelected(value: DropdownOption<T>?) {
self._selectedOption = value
self.setTitle(value?.value.dropdownText ?? placeholder, for: .normal)
}
}
// MARK: - Combine Binding
public extension DropdownButton where T: Hashable & DropdownDescribable {
func bind(to subject: CurrentValueSubject<DropdownOption<T>?, Never>, storeIn subscriptions: CancelBag) {
subject.sink { [weak self] value in
if value != self?._selectedOption {
self?._selectedOption = value
}
}.store(in: subscriptions)
self.selectedOption.sink { value in
if value != subject.value {
subject.send(value)
}
}.store(in: subscriptions)
}
func bindOptions(to subject: CurrentValueSubject<[DropdownOption<T>], Never>, storeIn subscriptions: CancelBag) {
subject.sink { [weak self] value in
if value != self?._options {
self?._options = value
}
}.store(in: subscriptions)
}
}
let cancelBag: CancelBag = CancelBag()
let selectedSize: CurrentValueSubject<DropdownOption<Int>?, Never> = .init(nil)
let button = DropdownButton<Int>()
button.bind(to: selectedSize, storeIn: cancelBag)
selectedSize.eraseToAnyPublisher()
.sink { value in
if value != nil {
selectedSize.value = nil
}
}
.store(in: cancelBag)
print("Send 1")
selectedSize.send(.init(value: 1))
print("Send 2")
selectedSize.send(.init(value: 2))
print("Send 3")
selectedSize.send(.init(value: 3))
print(selectedSize.value)
So the final result printed should be nil
. It does work some times but when executed several times there is also the case that the code ends up in an endless loop causing the Playground to freeze.
Currently I have no clue of how to solve this kind of issue. Maybe you know some way to solve it ? Any help is much appreciated !
A declarative, efficient, and flexible JavaScript library for building user interfaces.
๐ Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. ๐๐๐
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google โค๏ธ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.