У меня есть оболочка свойств, которая заставляет переменную находиться в диапазоне от 1 до 4.
@propertyWrapper
struct Between1and4 {
internal static let lowest = CGFloat(1.0)
internal static let highest = CGFloat(4.0)
private var value: CGFloat
var wrappedValue: CGFloat {
get { return value }
set { value = CGFloat(min(max(Between1and4.lowest, newValue), Between1and4.highest))
}
}
init(wrappedValue: CGFloat) {
self.value = wrappedValue
}
}
Теперь я хочу создать свойство класса Observable, которое будет @Published.
Что-то вроде
final class ContentViewModel: ObservableObject {
@published var scale:CGFloat
}
Но мне нужно, чтобы эта шкала имела тип Between1and4
, то есть эта структура должна работать как @Published
.
Как мне это сделать?
Теоретически @Published @Between1and4 var scale: CGFloat
должно помочь, но это не так. Я вижу это сообщение об ошибке:
Arguments to generic parameter 'Value' ('CGFloat' and 'Between1and4') are expected to be equal.
Cannot convert value of type 'ReferenceWritableKeyPath<ContentViewModel, CGFloat>' to expected argument type 'ReferenceWritableKeyPath<ContentViewModel, Between1and4>'
Теоретически это должно работать, но это не так. Я вижу это сообщение об ошибке: Arguments to generic parameter 'Value' ('CGFloat' and 'Between1and4') are expected to be equal
Да, я знаю, потому что @Published
— это своего рода специальная оболочка свойства. Он использует индекс _enclosingInstance
вместо wrappedValue
, как это делает любая другая оболочка свойства. К сожалению, из-за этого очень сложно создавать с его помощью другие оболочки свойств.
Чтобы добиться аналогичного поведения с вашей пользовательской оболочкой свойства @Between1and4
, вам необходимо вручную создать издателя внутри оболочки вашего свойства и указать его в качестве ProjectedValue.
@propertyWrapper
struct Between1and4 {
internal static let lowest = CGFloat(1.0)
internal static let highest = CGFloat(4.0)
private var value: CGFloat
private let subject: CurrentValueSubject<CGFloat, Never>
var projectedValue: AnyPublisher<CGFloat, Never> {
subject.eraseToAnyPublisher()
}
var wrappedValue: CGFloat {
get { value }
set {
let clampedValue = CGFloat(min(max(Between1and4.lowest, newValue), Between1and4.highest))
value = clampedValue
subject.send(value)
}
}
init(wrappedValue: CGFloat) {
let clampedValue = CGFloat(min(max(Between1and4.lowest, wrappedValue), Between1and4.highest))
self.value = clampedValue
self.subject = CurrentValueSubject(clampedValue)
}
}
Как использовать:
final class ContentViewModel: ObservableObject {
@Between1and4 var scale: CGFloat = 1.0
var cancellable: AnyCancellable?
init() {
cancellable = $scale.sink { newValue in
print("Scale changed to: \(newValue)")
}
}
}
По какой-то причине у меня это не работает. scale
указан двумя элементами. Один из них не меняется, как меняется scale
... не спрашивайте меня, почему. Если я изменю масштаб на @Published, это сработает.
ObservableObject
предназначен только для данных вашей модели, для данных вашего представления — @State
. Вы можете создать свою собственную обертку свойства @Between1and4
, которая обертывает @State
и выполняет ограничения следующим образом:
@propertyWrapper
struct Between1and4: DynamicProperty { // DynamicProperty is needed to use property wrappers
internal static let lowest = CGFloat(1.0)
internal static let highest = CGFloat(4.0)
static func limit(x: CGFloat) -> CGFloat{
return CGFloat(min(max(Between1and4.lowest, x), Between1and4.highest))
}
@State var value: CGFloat
init(wrappedValue: CGFloat) {
self.value = Self.limit(x: wrappedValue)
}
var wrappedValue: CGFloat {
get { return value }
nonmutating set {
value = Self.limit(x: newValue)
}
}
}
struct ContentView: View {
@Between1and4 var number = 0
var body: some View {
Button("\(number)") { // it will not go higher than 4
number += 1
}
Людям, которые проголосовали против, объясните, почему вы это сделали, чтобы другие люди могли понять, а автор мог исправить или оправдать свою позицию.
@Published
отличается от обычных оберток для свойств в том смысле, что из него не удаляется сахар из обычного wrappedValue
материала.
@Published var foo = 0
// the above DOES NOT lower to this:
private var _foo: Published<Int> = .init(wrappedValue: 0)
var foo: Int {
get { _foo.wrappedValue }
set { _foo.wrappedValue = newValue }
}
// instead it lowers to:
private var _foo: Published<Int> = .init(wrappedValue: 0)
var foo: Int {
get { _foo[_enclosingInstance: self, wrapped: \.foo, storage: \._foo] }
set { _foo[_enclosingInstance: self, wrapped: \.foo, storage: \._foo] = newValue }
}
Этот индекс _enclosingInstance
представляет собой скрытую функцию, которая позволяет оболочке свойства иметь доступ к экземпляру включающего класса (в данном случае ContentViewModel
). Предположительно, ему нужен доступ к издателю objectWillChange
. Вряд ли это станет официальной функцией.
Индекс объявляется следующим образом:
public static subscript<Root>(
_enclosingInstance instance: Root,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<Root, Value>,
storage storageKeyPath: ReferenceWritableKeyPath<Root, Self>
) -> Value
(Value
вот параметр типа Value
в Published<Value>
)
Если мы применим вышеуказанное преобразование к @Published @Between1and4 var scale
, вы увидите, что параметр wrapped
не соответствует. wrapped
ожидает ключевой путь от ContentViewModel
до Between1and4
, но мы передаем ключевой путь к CGFloat
.
Я предполагаю, что вам не нужно простое решение — просто объявить дополнительное свойство и не использовать Between1and4
в качестве оболочки свойства:
@Published private var _scale: Between1and4 = .init(wrappedValue: 3)
var scale: CGFloat {
get { _scale.wrappedValue }
set { _scale.wrappedValue = newValue }
}
Один из подходов к вашей проблеме — добавить такой индекс _enclosingInstance
к Between1and4
и вызвать этот индекс objectWillChange
, если и только если включающий экземпляр ObservableObject
использует реализацию objectWillChange
по умолчанию.
static subscript<EnclosingSelf>(
_enclosingInstance object: EnclosingSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, CGFloat>,
storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Between1and4>
) -> CGFloat {
get { object[keyPath: storageKeyPath].wrappedValue }
set {
if let observableObject = object as? (any ObservableObject),
let objectWillChange = (observableObject.objectWillChange as any Publisher) as? ObservableObjectPublisher {
objectWillChange.send()
}
object[keyPath: storageKeyPath].wrappedValue = newValue
}
}
По сути, вы создаете оболочку свойства, подобную @Published
, которая также имеет функциональность Between1and4
.
В ContentViewModel
вам больше не нужен @Published
. Просто напишите @Between1and4
и оно будет автоматически «опубликовано». Вот полный пример:
import SwiftUI
import Combine
final class ContentViewModel: ObservableObject {
@Between1and4 var scale: CGFloat = 1
}
struct ContentView: View {
@StateObject var model = ContentViewModel()
var body: some View {
Text(model.scale, format: .number)
// I deliberately set the range to 0...5 to show the effect of Between1and4
Slider(value: $model.scale, in: 0...5)
}
}
@propertyWrapper
struct Between1and4 {
internal static let lowest = CGFloat(1.0)
internal static let highest = CGFloat(4.0)
private var value: CGFloat
var wrappedValue: CGFloat {
get { return value }
set { value = CGFloat(min(max(Between1and4.lowest, newValue), Between1and4.highest))
}
}
init(wrappedValue: CGFloat) {
self.value = wrappedValue
}
static subscript<EnclosingSelf>(
_enclosingInstance object: EnclosingSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, CGFloat>,
storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Between1and4>
) -> CGFloat {
get { object[keyPath: storageKeyPath].wrappedValue }
set {
if let observableObject = object as? (any ObservableObject),
let objectWillChange = (observableObject.objectWillChange as any Publisher) as? ObservableObjectPublisher {
objectWillChange.send()
}
object[keyPath: storageKeyPath].wrappedValue = newValue
}
}
}
Одним из преимуществ этого подхода является то, что @Between1and4
в целом будет работать за пределами ObservableObject
. Индекс не будет работать, если охватывающий тип является типом значения, но компилятор, очевидно, достаточно умен, чтобы вернуться к использованию wrappedValue
в этом случае. Вы даже можете окружить нижний индекс #if canImport(...)
, чтобы он работал без ссылки Combine
/SwiftUI
. Индекс, вероятно, также может быть создан макросом-членом.
Одна из проблем заключается в том, что вы не можете получить Publisher
, используя префикс $
. Технически вы можете поставить CurrentValueSubject
внутри Between1and4
и использовать его в качестве издателя, но я думаю, что это немного перебор.
Спасибо за фантастическое объяснение, но, пожалуйста, извините за мое невежество, но вы меня потеряли. Каким будет окончательный код @Between1and4?
@Duck, просто добавьте нижний индекс (последний фрагмент кода) к @Between1and4
.
@Duck Я отредактировал ответ, добавив полный пример, если вы все еще в замешательстве.
фантастический. СПАСИБО!!!!
Вы имеете в виду, что хотите написать что-то вроде
@Published @Between1and4 var scale: CGFloat
?