Как создать собственный @Published propertyWrapper?

У меня есть оболочка свойств, которая заставляет переменную находиться в диапазоне от 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>'

Вы имеете в виду, что хотите написать что-то вроде @Published @Between1and4 var scale: CGFloat?

Sweeper 27.08.2024 14:27

Теоретически это должно работать, но это не так. Я вижу это сообщение об ошибке: Arguments to generic parameter 'Value' ('CGFloat' and 'Between1and4') are expected to be equal

Duck 27.08.2024 20:03

Да, я знаю, потому что @Published — это своего рода специальная оболочка свойства. Он использует индекс _enclosingInstance вместо wrappedValue, как это делает любая другая оболочка свойства. К сожалению, из-за этого очень сложно создавать с его помощью другие оболочки свойств.

Sweeper 28.08.2024 01:27
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
2
3
58
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

Чтобы добиться аналогичного поведения с вашей пользовательской оболочкой свойства @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, это сработает.

Duck 28.08.2024 18:40

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
            }

Людям, которые проголосовали против, объясните, почему вы это сделали, чтобы другие люди могли понять, а автор мог исправить или оправдать свою позицию.

Duck 28.08.2024 18:15
Ответ принят как подходящий

@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 28.08.2024 18:44

@Duck, просто добавьте нижний индекс (последний фрагмент кода) к @Between1and4.

Sweeper 29.08.2024 01:57

@Duck Я отредактировал ответ, добавив полный пример, если вы все еще в замешательстве.

Sweeper 30.08.2024 08:36

фантастический. СПАСИБО!!!!

Duck 30.08.2024 13:24

Другие вопросы по теме