Two-way databinding using Combine - swift

I'm building a reusable custom stepper view.
I have it all working, and am using Combine for observing value changes. However, I'd like to improve it by using two-way databinding, but not sure if that is possible?
Here is my current code:
class Stepper: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
#Published var count = 0
private let stackView = UIStackView()
private let minusButton = BorderedRoundButton()
private let plusButton = BorderedRoundButton()
private let countLabel = UILabel()
private var cancellables = Set<AnyCancellable>()
func setup() {
translatesAutoresizingMaskIntoConstraints = false
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .horizontal
minusButton.setTitleColor(.primaryTint, for: .normal)
plusButton.setTitleColor(.primaryTint, for: .normal)
minusButton.setTitle("-", for: .normal)
plusButton.setTitle("+", for: .normal)
countLabel.text = "0"
countLabel.textColor = .blackText
countLabel.font = .preferredFont(forTextStyle: .body)
countLabel.textAlignment = .center
stackView.addArrangedSubview(minusButton)
stackView.addArrangedSubview(countLabel)
stackView.addArrangedSubview(plusButton)
minusButton.tapPublisher.sink { [weak self] _ in
self?.count -= 1
}.store(in: &cancellables)
plusButton.tapPublisher.sink { [weak self] _ in
self?.count += 1
}.store(in: &cancellables)
$count.map { "\($0)" }.assign(to: \.text, on: countLabel).store(in: &cancellables)
$count.map { $0 > 0 }.assign(to: \.isEnabled, on: minusButton).store(in: &cancellables)
addSubview(stackView)
NSLayoutConstraint.activate([
minusButton.widthAnchor.constraint(equalToConstant: 38),
minusButton.heightAnchor.constraint(equalToConstant: 38),
plusButton.widthAnchor.constraint(equalToConstant: 38),
plusButton.heightAnchor.constraint(equalToConstant: 38),
countLabel.widthAnchor.constraint(equalToConstant: 44),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
}
And I am using it like this:
class PickerViewController: UIViewController {
#IBOutlet private var stepper: Stepper!
private var cancellables = Set<AnyCancellable>()
#Published var count = 0
override func viewDidLoad() {
super.viewDidLoad()
stepper.count = count
stepper.$count.sink { [weak self] value in
self?.count = value
}.store(in: &cancellables)
}
}
And that is the bit I want to improve: I now need to both set the initial value and then observe the changes. I know it's hardly a lot of code, but I am just wondering if I can do it in one line, with a sort of two-way databinding?
I thought about using SwiftUI's #Bindable propertywrapper (or a rebuilt version of it as to not have to import SwiftUI), so that PickerViewController.count is the one source, and the Stepper would automatically update it. However, then I have the problem that #Bindable is not observable itself, so these lines would no longer work:
$count.map { "\($0)" }.assign(to: \.text, on: countLabel).store(in: &cancellables)
$count.map { $0 > 0 }.assign(to: \.isEnabled, on: minusButton).store(in: &cancellables)
So my question is: can I improve the code to use two-way databinding, or should I just stick with setting the value and then observing it separately?

Related

How to re-publish a PassthroughSubject using another publisher in Combine

Current (working) situation:
In our app we have several publishers of type PassthroughSubject<Void, Never>.
The subscriber of this publisher send out the same type of publisher within the .sink() closure. In a simple playground it would look like that:
//: A UIKit based Playground for presenting user interface
import UIKit
import PlaygroundSupport
import Combine
class MyViewController : UIViewController {
// MARK: - Observables
let initialPublisher: PassthroughSubject = PassthroughSubject<Void, Never>()
let rePublisher = PassthroughSubject<Void, Never>()
// MARK: - Observer
private var cancellableSubscriber = Set<AnyCancellable>()
override func loadView() {
// MARK: - View Setup
let view = UIView()
let button = UIButton(type: .system)
button.frame = CGRect(x: 100, y: 100, width: 200, height: 20)
button.setTitle("Button", for: .normal)
button.addTarget(self, action: #selector(buttonAction), for: .touchUpInside)
view.addSubview(button)
self.view = view
// MARK: - Subscriptions
// Event of initial publisher is received and re-published using another subject.
initialPublisher
.sink { [weak self] in
self?.rePublisher.send()
}
.store(in: &cancellableSubscriber)
// The re-published event is received.
rePublisher
.sink {
print("Received!")
}
.store(in: &cancellableSubscriber)
}
#objc private func buttonAction() {
self.initialPublisher.send()
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()
Prefered (non-working) solution:
Instead of subscribing and re publishing using the .sink() closure and another PassthroughSubject I wanted to re-publish the initial publisher using .receive(subscriber: AnySubscriber) However somehow it doesn't seem to work or maybe I'm understanding the .receive method wrong. I tried the following without luck.
Question:
How can I make the below code work, or is it even the correct way? If not, are there more elegant ways to re-publish than in our code above?
Clarification:
If something is unclear or of you need further examples please leave a comment below and I will try to update my question.
class MyViewController : UIViewController {
// MARK: - Observables
let initialPublisher: PassthroughSubject = PassthroughSubject<Void, Never>()
let rePublisher = PassthroughSubject<Void, Never>()
var subscriber = AnySubscriber<Void, Never>()
// MARK: - Observer
private var cancellableSubscriber = Set<AnyCancellable>()
override func loadView() {
// MARK: - View Setup
let view = UIView()
let button = UIButton(type: .system)
button.frame = CGRect(x: 100, y: 100, width: 200, height: 20)
button.setTitle("Button", for: .normal)
button.addTarget(self, action: #selector(buttonAction), for: .touchUpInside)
view.addSubview(button)
self.view = view
// MARK: - Subscriptions
//Republishing
subscriber = AnySubscriber(initialPublisher)
// Event of initial publisher is received and re-published.
rePublisher.receive(subscriber: subscriber)
// // The re-published event is received.
rePublisher
.sink {
print("Received!") // <-- does not work!
}
.store(in: &cancellableSubscriber)
}
#objc private func buttonAction() {
self.initialPublisher.send()
}
}
I think you are working too hard. Just pass the AnyPublisher around instead of trying to tie two Subjects together. It doesn't even make sense to try to tie them together because anybody can call send on either of them.
class MyViewController : UIViewController {
let initialPublisher: PassthroughSubject = PassthroughSubject<Void, Never>()
var rePublisher: AnyPublisher<Void, Never> {
initialPublisher.eraseToAnyPublisher()
}
private var cancellableSubscriber = Set<AnyCancellable>()
override func loadView() {
super.loadView()
let button: UIButton = {
let result = UIButton(type: .system)
result.frame = CGRect(x: 100, y: 100, width: 200, height: 20)
result.setTitle("Button", for: .normal)
result.addTarget(self, action: #selector(buttonAction), for: .touchUpInside)
return result
}()
view.backgroundColor = .white
view.addSubview(button)
rePublisher
.sink {
print("Received!") // works!
}
.store(in: &cancellableSubscriber)
}
#objc private func buttonAction() {
initialPublisher.send()
}
}

How can i use Combine with #resultbuilder to build a dynamic collectionview list?

I want to use #resultbuilder and Combine to create my own reactive and declarative UICollectionView List in UIKit, similiar to what we get with List {} in SwiftUI.
For that, i am using a resultbuilder to create a Snapshot like this:
#resultBuilder
struct SnapshotBuilder {
static func buildBlock(_ components: ListItemGroup...) -> [ListItem] {
return components.flatMap { $0.items }
}
// Support `for-in` loop
static func buildArray(_ components: [ListItemGroup]) -> [ListItem] {
return components.flatMap { $0.items }
}
static func buildFinalResult(_ component: [ListItem]) -> NSDiffableDataSourceSectionSnapshot<ListItem> {
var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()
sectionSnapshot.append(component)
return sectionSnapshot
}
}
I also need to use the following extensions to pass ListItemGroup to SnapshotBuilder and get [ListItem]
struct ListItem: Hashable {
let title: String
let image: UIImage?
var children: [ListItem]
init(_ title: String, children: [ListItem] = []) {
self.title = title
self.image = UIImage(systemName: title)
self.children = children
}
}
protocol ListItemGroup {
var items: [ListItem] { get }
}
extension Array: ListItemGroup where Element == ListItem {
var items: [ListItem] { self }
}
extension ListItem: ListItemGroup {
var items: [ListItem] { [self] }
}
My List Class looks like this:
final class List: UICollectionView {
enum Section {
case main
}
var data: UICollectionViewDiffableDataSource<Section, ListItem>!
private var cancellables = Set<AnyCancellable>()
init(_ items: Published<[String]>.Publisher, style: UICollectionLayoutListConfiguration.Appearance = .insetGrouped, #SnapshotBuilder snapshot: #escaping () -> NSDiffableDataSourceSectionSnapshot<ListItem>) {
super.init(frame: .zero, collectionViewLayout: List.createLayout(style))
configureDataSource()
data.apply(snapshot(), to: .main)
items
.sink { newValue in
let newSnapshot = snapshot()
self.data.apply(newSnapshot, to: .main, animatingDifferences: true)
}
.store(in: &cancellables)
}
required init(coder: NSCoder) {
super.init(coder: coder)!
}
private static func createLayout(_ appearance: UICollectionLayoutListConfiguration.Appearance) -> UICollectionViewLayout {
let layoutConfig = UICollectionLayoutListConfiguration(appearance: appearance)
return UICollectionViewCompositionalLayout.list(using: layoutConfig)
}
private func configureDataSource() {
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, ListItem> {
(cell, indexPath, item) in
var content = cell.defaultContentConfiguration()
content.image = item.image
content.text = item.title
cell.contentConfiguration = content
}
data = UICollectionViewDiffableDataSource<Section, ListItem>(collectionView: self) {
(collectionView: UICollectionView, indexPath: IndexPath, identifier: ListItem) -> UICollectionViewCell? in
let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
cell.accessories = [.disclosureIndicator()]
return cell
}
}
}
And i am using it in my ViewControllers like this:
class DeclarativeViewController: UIViewController {
#Published var testItems: [String] = []
var collectionView: List!
override func viewDidLoad() {
super.viewDidLoad()
navigationController?.navigationBar.sizeToFit()
title = "Settings"
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "plus"), style: .plain, target: self, action: #selector(addItem))
view.backgroundColor = .systemBackground
collectionView = List($testItems) {
for item in self.testItems {
ListItem(item)
}
}
collectionView.frame = view.bounds
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(collectionView)
}
#objc func addItem() {
testItems.append("Item \(testItems.count)")
}
}
As you can see, i initialize my List with the #Published var testItems variable. In my init() func, i setup a subscriber and store them in cancellables, so i can react on changes.
If i add an item to testItems array, the sink callback is exectued to create a new snapshot and apply them to data. It works, but i need to tap the navigation button twice, to see an item on the list. Two questions:
Why this is happen and how can i solve this? (so i only need to tap the button once to see changes in my list)
and how can i improve my code? (currently I always create a new snapshot instead of extending the already created one)
Let me answer both questions by answering your second one.
How can i improve my code? (currently I always create a new snapshot
instead of extending the already created one)
I'm a bit confused about your use of #resultBuilder. Typically one would use a result builder to create a Domain Specific Language (DSL). In this case you could create a DSL for constructing ListItems, but that would imply that you mean to populate a list at compile time, most of your code here seems to focus on updating the list, dynamically, a runtime. So using result builder seems overly complex.
In this case, you're also using a Publisher where you could probably get by using a simple didSet on your controller's property. However, a Publisher would be a really good idea as part of a more complex Model that the Controller was trying to coordinate with its views. I had a version of your code where I replaced the Publisher with didSet but on second glance - imaging the more complex model case, I put the publisher back in.
You've got your publisher's pipeline all tangled up in your result builder - which is odd because, again, publishers are about reacting dynamically to changes at runtime whereas result builders are about making nice DSLs for the syntax sugaring of compile time code.
So I pulled out the DSL, and set up a rich pipeline that makes good use of having a publisher.
Also, when using Combine publishers, it's common to use type erasure to make the actual nature of the publisher more anonymous. So in my rework, I use eraseToAnyPublisher so that List could take it's values from anyone, not just an #Published list of strings.
So List becomes:
final class List: UICollectionView {
enum Section {
case main
}
private var subscriptions = Set<AnyCancellable>()
private var data: UICollectionViewDiffableDataSource<Section, ListItem>!
init(itemPublisher: AnyPublisher<[String], Never>,
style: UICollectionLayoutListConfiguration.Appearance = .insetGrouped) {
super.init(frame: .zero, collectionViewLayout: List.createLayout(style))
configureDataSource()
itemPublisher
.map{ items in items.map { ListItem($0) }}
.map{ listItems in
var newSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()
newSnapshot.append(listItems)
return newSnapshot
}
.sink {
newSnapshot in
self.data?.apply(newSnapshot, to: .main, animatingDifferences: true)
}
.store(in: &subscriptions)
}
required init?(coder : NSCoder) {
super.init(coder: coder)
}
private static func createLayout(_ appearance: UICollectionLayoutListConfiguration.Appearance) -> UICollectionViewLayout {
let layoutConfig = UICollectionLayoutListConfiguration(appearance: appearance)
return UICollectionViewCompositionalLayout.list(using: layoutConfig)
}
private func configureDataSource() {
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, ListItem> {
(cell, indexPath, item) in
var content = cell.defaultContentConfiguration()
content.image = item.image
content.text = item.title
cell.contentConfiguration = content
}
data = UICollectionViewDiffableDataSource<Section, ListItem>(collectionView: self) {
(collectionView: UICollectionView, indexPath: IndexPath, identifier: ListItem) -> UICollectionViewCell? in
let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
cell.accessories = [.disclosureIndicator()]
return cell
}
}
}
Note the rich processing pipeline that is set up for itemPublisher and that it comes into the class as AnyPublisher<[String], Never>.
Then your DeclarativeViewController becomes:
class DeclarativeViewController: UIViewController {
#Published var testItems: [String] = []
var collectionView: List!
override func viewDidLoad() {
super.viewDidLoad()
navigationController?.navigationBar.sizeToFit()
title = "Settings"
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "plus"), style: .plain, target: self, action: #selector(addItem))
view.backgroundColor = .systemBackground
collectionView = List(itemPublisher: $testItems.eraseToAnyPublisher())
collectionView.frame = view.bounds
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(collectionView)
}
#objc func addItem() {
testItems.append("Item \(testItems.count)")
}
}
where the testItems model's publisher get erased away to an any publisher.
In my code ListItem stays the same, but all the stuff related to the #resultBuiler is gone. Maybe you could use it if you wanted to create a funciton to build a set of ListItems for the initial set of items in a table (or for a table that has static content) But it didn't seem necessary here.

End or Dismiss Lottie Animation View if playOnce is finished in Swift

I already tried both loadingview.removeFromSuperView and loadingView.isHidden = true
Yes, it removes or hides the view, but I can't click on my root view anymore.
I also tried animatonview.background = .forceFinish, but doesn't do the job.
import UIKit
import Lottie
class LoadingAnimationView: UIView {
#IBOutlet weak var loadingView: UIView!
let animationView = AnimationView()
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
override func awakeFromNib() {
super.awakeFromNib()
}
func loadAnimation() {
let animation = Animation.named("success")
animationView.animation = animation
animationView.contentMode = .scaleAspectFill
loadingView.addSubview(animationView)
animationView.backgroundBehavior = .pauseAndRestore
animationView.translatesAutoresizingMaskIntoConstraints = false
animationView.topAnchor.constraint(equalTo: loadingView.layoutMarginsGuide.topAnchor).isActive = true
animationView.leadingAnchor.constraint(equalTo: loadingView.leadingAnchor, constant: 0).isActive = true
animationView.bottomAnchor.constraint(equalTo: loadingView.bottomAnchor).isActive = true
animationView.trailingAnchor.constraint(equalTo: loadingView.trailingAnchor, constant:0).isActive = true
animationView.setContentCompressionResistancePriority(.fittingSizeLevel, for: .horizontal)
animationView.play(fromProgress: 0,
toProgress: 1,
loopMode: .playOnce,
completion: { (finished) in
if finished {
print("Animation Complete")
//please put solution here? dismiss or end loadingView or animationView
} else {
print("Animation cancelled")
}
})
}
EDIT 2:
I'm using the loadingView when the success message is received or 200.
func goOnlineMode(){
APIManager.sharedInstance.fetchServerStatus(completion: { data, error in
if error != nil{
print("Connection Failed")
} else {
if data?.status == 200 || data?.msg == "success" {
print("connected")
loadAnimation(true)
self.setCloudStateValue(value: true)
self.vc.cloudstateChecker()
} else {
print("fail to connect")
}
}
})
}
}
this is my function loading boolean in loadAnimation for Loading the xib.
func loadAnimation(_ display: Bool) {
if (display) {
let window = UIApplication.shared.keyWindow!
if Singleton.animationView == nil {
if let view = Bundle.main.loadNibNamed("LoadingAnimationView", owner: window, options:nil)![0] as? LoadingAnimationView {
Singleton.animationView = view
Singleton.animationView?.frame.size = CGSize(width: window.bounds.width, height: window.bounds.height)
window.addSubview(Singleton.animationView!)
window.layoutIfNeeded()
Singleton.animationView?.loadAnimation()
Singleton.animationView?.translatesAutoresizingMaskIntoConstraints = false
Singleton.animationView?.leftAnchor.constraint(equalTo: window.leftAnchor).isActive = true
Singleton.animationView?.rightAnchor.constraint(equalTo: window.rightAnchor).isActive = true
Singleton.animationView?.topAnchor.constraint(equalTo: window.topAnchor, constant:-60).isActive = true
Singleton.animationView?.bottomAnchor.constraint(equalTo: window.bottomAnchor).isActive = true
window.layoutIfNeeded()
}
}
} else {
if (Singleton.animationView != nil) {
Singleton.animationView?.removeFromSuperview()
Singleton.animationView = nil
}
}
}
Try with this:
Swift 5
animationView.play { (finished) in
animationViewNewOrder!.isHidden = true
}
I solved my problem by using NotificationCenter
Swift 4.2
Add this NotificationCenter Observer in your MainViewController, and also register a Notification.Name to your Constants
NotificationCenter.default.addObserver(self, selector: #selector(removeAnimation(notification:)), name: HIDE_ANIMATION, object: nil)
}
also add this together with your observer
#objc func removeAnimation(notification:NSNotification) {
loadingAnimation(false)
}
I put this Notification Post in my newly created hideAnimation function in LoadingAnimationView.
func hideAnimation() {
NotificationCenter.default.post(name: Notification.Name(HIDE_ANIMATION.rawValue), object: nil)
loadingView.removeFromSuperview()
}
and put the hideAnimation function to your completion finish.

How does iOS Custom Keyboard buttons action triggered?

I am trying to make custom keyboard extension. Keyboard buttons showing nicely but no action triggered! Here is Buttons UI:
struct MyKeyButtons: View {
let data: [String] = ["A", "B", "C"]
var body: some View {
HStack {
ForEach(data, id: \.self) { aData in
Button(action: {
KeyboardViewController().keyPressed()
}) {
Text(aData)
.fontWeight(.bold)
.font(.title)
.foregroundColor(.purple)
.padding()
.border(Color.purple, width: 5)
}
}
}
}
}
The KeyboardViewController code here:
import SwiftUI
class KeyboardViewController: UIInputViewController {
#IBOutlet var nextKeyboardButton: UIButton!
override func updateViewConstraints() {
super.updateViewConstraints()
// Add custom view sizing constraints here
}
override func viewDidLoad() {
super.viewDidLoad()
let child = UIHostingController(rootView: MyKeyButtons())
//that's wrong, it must be true to make flexible constraints work
// child.translatesAutoresizingMaskIntoConstraints = false
child.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(child.view)
addChild(child)//not sure what is this for, it works without it.
// Perform custom UI setup here
self.nextKeyboardButton = UIButton(type: .system)
self.nextKeyboardButton.setTitle(NSLocalizedString("Next Keyboard", comment: "Title for 'Next Keyboard' button"), for: [])
self.nextKeyboardButton.sizeToFit()
self.nextKeyboardButton.translatesAutoresizingMaskIntoConstraints = false
self.nextKeyboardButton.addTarget(self, action: #selector(handleInputModeList(from:with:)), for: .allTouchEvents)
self.view.addSubview(self.nextKeyboardButton)
self.nextKeyboardButton.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true
self.nextKeyboardButton.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
}
override func viewWillLayoutSubviews() {
self.nextKeyboardButton.isHidden = !self.needsInputModeSwitchKey
super.viewWillLayoutSubviews()
}
override func textWillChange(_ textInput: UITextInput?) {
// The app is about to change the document's contents. Perform any preparation here.
}
override func textDidChange(_ textInput: UITextInput?) {
// The app has just changed the document's contents, the document context has been updated.
var textColor: UIColor
let proxy = self.textDocumentProxy
if proxy.keyboardAppearance == UIKeyboardAppearance.dark {
textColor = UIColor.white
} else {
textColor = UIColor.black
}
self.nextKeyboardButton.setTitleColor(textColor, for: [])
}
//==================================
func keyPressed() {
print("test--- clicked! ")
//textDocumentProxy.insertText("a")
(textDocumentProxy as UIKeyInput).insertText("a")
}
}
For more info see the GitHub project: https://github.com/ask2asim/KeyboardTest1

Target-Action problems with custom view built from standard views

I have a custom view subclassing NSView, which is just an NSStackView containing a label, slider, a second label and a checkbox. The slider and checkbox are both configured to report changes to the view (and eventually, via a delegate to a ViewController):
fileprivate extension NSTextField {
static func label(text: String? = nil) -> NSTextField {
let label = NSTextField()
label.isEditable = false
label.isSelectable = false
label.isBezeled = false
label.drawsBackground = false
label.stringValue = text ?? ""
return label
}
}
#IBDesignable
class Adjustable: NSView {
private let sliderLabel = NSTextField.label()
private let slider = NSSlider(target: self, action: #selector(sliderChanged(_:)))
private let valueLabel = NSTextField.label()
private let enabledCheckbox = NSButton(checkboxWithTitle: "Enabled", target: self, action: #selector(enabledChanged(_:)))
var valueFormatter: (Double)->(String) = { String(format:"%5.2f", $0) }
...
#objc func sliderChanged(_ sender: Any) {
guard let slider = sender as? NSSlider else { return }
valueLabel.stringValue = valueFormatter(slider.doubleValue)
print("Slider now: \(slider.doubleValue)")
delegate?.adjustable(self, changedValue: slider.doubleValue)
}
#objc func enabledChanged(_ sender: Any) {
guard let checkbox = sender as? NSButton else { return }
print("Enabled now: \(checkbox.state == .on)")
delegate?.adjustable(self, changedEnabled: checkbox.state == .on)
}
}
Using InterfaceBuilder, I can add one instance of this to a ViewController by dragging in a CustomView and setting it's class in the Identity Inspector. Toggling the checkbox or changing the slider will have the desired effect.
However, if I have multiple instances then in the target-action functions self will always refer to the same instance of the view, rather than the one being interacted with. In other words, self.slider == sender is only true in sliderChanged for one of the sliders. While I can get the correct slider value via sender, I cannot update the correct label as self.valueLabel is always the label in the first instance of the custom view.
Incidentally, #IBDesignable and the code intended to support it have no effect so there's something I'm missing there too - Interface Builder just shows empty space.
The whole file:
import Cocoa
fileprivate extension NSTextField {
static func label(text: String? = nil) -> NSTextField {
let label = NSTextField()
label.isEditable = false
label.isSelectable = false
label.isBezeled = false
label.drawsBackground = false
label.stringValue = text ?? ""
return label
}
}
protocol AdjustableDelegate {
func adjustable(_ adjustable: Adjustable, changedEnabled: Bool)
func adjustable(_ adjustable: Adjustable, changedValue: Double)
}
#IBDesignable
class Adjustable: NSView {
var delegate: AdjustableDelegate? = nil
private let sliderLabel = NSTextField.label()
private let slider = NSSlider(target: self, action: #selector(sliderChanged(_:)))
private let valueLabel = NSTextField.label()
private let enabledCheckbox = NSButton(checkboxWithTitle: "Enabled", target: self, action: #selector(enabledChanged(_:)))
var valueFormatter: (Double)->(String) = { String(format:"%5.2f", $0) }
#IBInspectable
var label: String = "" {
didSet {
sliderLabel.stringValue = label
}
}
#IBInspectable
var value: Double = 0 {
didSet {
slider.doubleValue = value
valueLabel.stringValue = valueFormatter(value)
}
}
#IBInspectable
var enabled: Bool = false {
didSet {
enabledCheckbox.isEnabled = enabled
}
}
#IBInspectable
var minimum: Double = 0 {
didSet {
slider.minValue = minimum
}
}
#IBInspectable
var maximum: Double = 100 {
didSet {
slider.maxValue = maximum
}
}
#IBInspectable
var tickMarks: Int = 0
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder decoder: NSCoder) {
super.init(coder: decoder)
setup()
}
override func prepareForInterfaceBuilder() {
setup()
}
override func awakeFromNib() {
setup()
}
private func setup() {
let stack = NSStackView()
stack.orientation = .horizontal
stack.translatesAutoresizingMaskIntoConstraints = false
stack.addArrangedSubview(sliderLabel)
stack.addArrangedSubview(slider)
stack.addArrangedSubview(valueLabel)
stack.addArrangedSubview(enabledCheckbox)
sliderLabel.stringValue = label
slider.doubleValue = value
valueLabel.stringValue = valueFormatter(value)
slider.minValue = minimum
slider.maxValue = maximum
slider.numberOfTickMarks = tickMarks
// Make the slider be the one that expands to fill available space
slider.setContentHuggingPriority(NSLayoutConstraint.Priority(rawValue: 249), for: .horizontal)
sliderLabel.widthAnchor.constraint(equalToConstant: 60).isActive = true
valueLabel.widthAnchor.constraint(equalToConstant: 60).isActive = true
addSubview(stack)
stack.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
stack.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
stack.topAnchor.constraint(equalTo: topAnchor).isActive = true
stack.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
}
#objc func sliderChanged(_ sender: Any) {
guard let slider = sender as? NSSlider else { return }
valueLabel.stringValue = valueFormatter(slider.doubleValue)
print("Slider now: \(slider.doubleValue)")
delegate?.adjustable(self, changedValue: slider.doubleValue)
}
#objc func enabledChanged(_ sender: Any) {
guard let checkbox = sender as? NSButton else { return }
print("Enabled now: \(checkbox.state == .on)")
delegate?.adjustable(self, changedEnabled: checkbox.state == .on)
}
}
The solution, as described in the question linked by Willeke, was to ensure init had completed before referencing self. (I'm slightly surprised the compiler allowed it to be used in a property initialiser)
Wrong:
private let slider = NSSlider(target: self, action: #selector(sliderChanged(_:)))
private let enabledCheckbox = NSButton(checkboxWithTitle: "Enabled", target: self, action: #selector(enabledChanged(_:)))
Right:
private lazy var slider = NSSlider(target: self, action: #selector(sliderChanged(_:)))
private lazy var enabledCheckbox = NSButton(checkboxWithTitle: "Enabled", target: self, action: #selector(enabledChanged(_:)))