ZupIT / beagle

A framework to help implement Server-Driven UI in your apps natively.
https://docs.usebeagle.io
Apache License 2.0
686 stars 90 forks source link

How to create a DatePicker to manipulate the value in a context #1477

Closed minmon98 closed 3 years ago

Tiagoperes commented 3 years ago

Hello @minmon98. Can you please elaborate the question? Maybe give us some examples of what you're trying to achieve.

minmon98 commented 3 years ago

Ohh sorry @Tiagoperes for my mistakes. I created a custom action to show a date picker on native iOS. when I pick a date, I don't know how to trigger to beagle screen know the data

Tiagoperes commented 3 years ago

I think there's a simpler approach to this. Instead of opening a DatePicker via a custom action, you should create a new component.

This component is a Date Input. It shows the date value of the input, or a placeholder in the case no value has been set. when the user presses the input, a DatePicker is shown. When the user selects a date in the DatePicker, the value of the input changes to the selected date.

This custom component (DateInput) must have at least these two attributes:

In your backend, the view which uses a DatePicker would be similar to this:

{
  "_beagleComponent_": "beagle:container",
  "context": {
    "id": "form",
    "value": {
      "date": "2021-04-12"
    }
  },
  "children": [
    {
      "_beagleComponent_": "custom:dateInput",
      "value": "@{form.date}",
      "onChange": [
        {
          "_beagleAction_": "beagle:setContext",
          "contextId": "form",
          "path": "date",
          "value": "@{onChange.value}"
        }
      ]
    }
  ]
}

In summary, your custom component DateInput will be like any other input, but instead of opening a keyboard to type the value, it will open a DatePicker. The date value must be serialized somehow. Here I assumed the format year-month-day.

The view in the example will render a DateInput that, when changed, will alter the value in the context. You can use this context for whatever you want after that. Note that I used form.date, I did this because you'll probably want more fields in this form. If this is not the case, you can declare the context as a string instead of a <key, value> map.

But, there's one thing missing: how can you create a component that interacts with actions passed by Beagle. In other words, how do you implement the onChange event of the component?

This is our bad, we don't say how to do this in our documentation. We'll fix this asap. In any case I asked @lucasaraujo, from the iOS team, to help you with this. He will create an example of the DateInput component and as soon as it's ready, he'll post it here.

lucasaraujo commented 3 years ago

Here is a sample to help you implement an input component.

First let's define our component, in this case a DatePicker:

public struct DatePicker: ServerDrivenComponent {

    public var date: Expression<String>? // [1] It's an Expression to allow us to read and set the value using a Context.
    public var onChange: [Action]? // [2] This Actions allow us to respond to changes in the component.

    public func toView(renderer: BeagleRenderer) -> UIView {
        // the Expression binding and Action execution will be handled in the class DatePickerView.
        let datePicker = DatePickerView(self, renderer: renderer)
        return datePicker
    }
}

extension DatePicker {
    enum CodingKeys: String, CodingKey {
        case date
        case onChange
    }
    /// The decoder implementation is required because `Action` is a protocol, so we have to provide it.
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        date = try container.decodeIfPresent(Expression<String>.self, forKey: .date)
        // Beagle provide `KeyedDecodingContainer` extension methods to decode registered actions and components.
        onChange = try container.decodeIfPresent(forKey: .onChange)
    }
}

The implementation will use the native iOS DatePicker view:

class DatePickerView: UIDatePicker {

    let component: DatePicker
    weak var controller: BeagleController?

    private static var formatter: ISO8601DateFormatter = {
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = .withFullDate
        formatter.timeZone = TimeZone.autoupdatingCurrent
        return formatter
    }()

    private var dateString: String? {
        get { Self.formatter.string(from: date) }
        set {
            guard let string = newValue,
                  let date = Self.formatter.date(from: string) else { return }
            self.date = date
        }
    }

    init(_ component: DatePicker, renderer: BeagleRenderer) {
        self.component = component
        self.controller = renderer.controller
        super.init(frame: .zero)

        datePickerMode = .date
        preferredDatePickerStyle = .compact

        // [1] Use the renderer method `observe` to bind the expressions
        renderer.observe(component.date, andUpdate: \.dateString, in: self)

        addTarget(self, action: #selector(valueChanged), for: .valueChanged)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    @objc private func valueChanged() {
        guard let dateString = dateString else { return }
        // [2] Here the actions are executed and the new value is exposed through the implicit context `onChange`
        controller?.execute(actions: component.onChange, with: "onChange", and: .string(dateString), origin: self)
    }

    override func sizeThatFits(_ size: CGSize) -> CGSize {
        return super.sizeThatFits(.zero)
    }
}

Finally we can register and use the component:

Beagle.dependencies.decoder.register(component: DatePicker.self)
// Using the swift DSL
let viewController = BeagleScreenViewController(
    Container(context: Context(id: "ctx", value: ["date": "2020-01-31"])) {
        DatePicker(
            date: "@{ctx.date}",
            onChange: [SetContext(contextId: "ctx", path: "date", value: "@{onChange}")]
        )
        Text("The date is @{ctx.date}")
    }
)

Here is the above sample in json:

{
  "_beagleComponent_": "beagle:container",
  "context": {
    "id": "ctx",
    "value": {
      "date": "2020-01-31"
    }
  },
  "children": [
    {
      "_beagleComponent_": "custom:datePicker",
      "date": "@{ctx.date}",
      "onChange": [
        {
          "_beagleAction_": "beagle:setContext",
          "contextId": "ctx",
          "path": "date",
          "value": "@{onChange}"
        }
      ]
    },
    {
        "_beagleComponent_": "beagle:text",
        "text": "The date is @{ctx.date}"
    }
  ]
}
minmon98 commented 3 years ago

Woww thanks a lot for your support @Tiagoperes , @lucasaraujo . It's exactly the response I'm waiting for

hoangngv commented 3 years ago

Here is a sample to help you implement an input component.

First let's define our component, in this case a DatePicker:

public struct DatePicker: ServerDrivenComponent {

    public var date: Expression<String>? // [1] It's an Expression to allow us to read and set the value using a Context.
    public var onChange: [Action]? // [2] This Actions allow us to respond to changes in the component.

    public func toView(renderer: BeagleRenderer) -> UIView {
        // the Expression binding and Action execution will be handled in the class DatePickerView.
        let datePicker = DatePickerView(self, renderer: renderer)
        return datePicker
    }
}

extension DatePicker {
    enum CodingKeys: String, CodingKey {
        case date
        case onChange
    }
    /// The decoder implementation is required because `Action` is a protocol, so we have to provide it.
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        date = try container.decodeIfPresent(Expression<String>.self, forKey: .date)
        // Beagle provide `KeyedDecodingContainer` extension methods to decode registered actions and components.
        onChange = try container.decodeIfPresent(forKey: .onChange)
    }
}

The implementation will use the native iOS DatePicker view:

class DatePickerView: UIDatePicker {

    let component: DatePicker
    weak var controller: BeagleController?

    private static var formatter: ISO8601DateFormatter = {
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = .withFullDate
        formatter.timeZone = TimeZone.autoupdatingCurrent
        return formatter
    }()

    private var dateString: String? {
        get { Self.formatter.string(from: date) }
        set {
            guard let string = newValue,
                  let date = Self.formatter.date(from: string) else { return }
            self.date = date
        }
    }

    init(_ component: DatePicker, renderer: BeagleRenderer) {
        self.component = component
        self.controller = renderer.controller
        super.init(frame: .zero)

        datePickerMode = .date
        preferredDatePickerStyle = .compact

        // [1] Use the renderer method `observe` to bind the expressions
        renderer.observe(component.date, andUpdate: \.dateString, in: self)

        addTarget(self, action: #selector(valueChanged), for: .valueChanged)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    @objc private func valueChanged() {
        guard let dateString = dateString else { return }
        // [2] Here the actions are executed and the new value is exposed through the implicit context `onChange`
        controller?.execute(actions: component.onChange, with: "onChange", and: .string(dateString), origin: self)
    }

    override func sizeThatFits(_ size: CGSize) -> CGSize {
        return super.sizeThatFits(.zero)
    }
}

Finally we can register and use the component:

Beagle.dependencies.decoder.register(component: DatePicker.self)
// Using the swift DSL
let viewController = BeagleScreenViewController(
    Container(context: Context(id: "ctx", value: ["date": "2020-01-31"])) {
        DatePicker(
            date: "@{ctx.date}",
            onChange: [SetContext(contextId: "ctx", path: "date", value: "@{onChange}")]
        )
        Text("The date is @{ctx.date}")
    }
)

Here is the above sample in json:

{
  "_beagleComponent_": "beagle:container",
  "context": {
    "id": "ctx",
    "value": {
      "date": "2020-01-31"
    }
  },
  "children": [
    {
      "_beagleComponent_": "custom:datePicker",
      "date": "@{ctx.date}",
      "onChange": [
        {
          "_beagleAction_": "beagle:setContext",
          "contextId": "ctx",
          "path": "date",
          "value": "@{onChange}"
        }
      ]
    },
    {
        "_beagleComponent_": "custom:text",
        "text": "The date is @{ctx.date}"
    }
  ]
}

Sorry for any inconvenience. I'm working on the same project with @minmon98 and can you show me how to implement it on Android? Thank you in advance! @Tiagoperes @lucasaraujo

Tiagoperes commented 3 years ago

@hoangnv11 sure, we can also implement this example for Android. @luisoliveirazup will help you with this.

We are going to use these examples to update our documentation. Thanks for bringing this issue to our attention!

hoangngv commented 3 years ago

@hoangnv11 sure, we can also implement this example for Android. @luisoliveirazup will help you with this.

We are going to use these examples to update our documentation. Thanks for bringing this issue to our attention!

Oh thank you guys. I'm looking forward to your example. I believe that this will help a lot of developers in our Beagle community :D

luisoliveirazup commented 3 years ago

Here is a sample to help you implement an android component.

First let's define our component, in this case a DatePicker:

@RegisterWidget
class DatePicker(
    val date: Bind<String>,
    val onChange: List<Action>
) : WidgetView() {

    override fun buildView(rootView: RootView) = DatePickerComponent(rootView.getContext()).apply {

        observeBindChanges(rootView, this, date) { text ->
            text?.let { setText(it) }
        }

        dateSetListener = object : DateSetListener {
            override fun onDateSet(value: String) {
                this@DatePicker.handleEvent(
                    rootView,
                    this@apply,
                    onChange,
                    ContextData(
                        id = "onChange",
                        value = value
                    )
                )
            }
        }
    }
}

The implementation will use the native Android DatePicker view:

class DatePickerComponent constructor(
    context: Context
) : TextView(context), DatePickerDialog.OnDateSetListener {

    private val myCalendar: Calendar = Calendar.getInstance()
    var dateSetListener: DateSetListener? = null

    init {
        this.setOnClickListener {
            DatePickerDialog(context, this, myCalendar
                .get(Calendar.YEAR), myCalendar.get(Calendar.MONTH),
                myCalendar.get(Calendar.DAY_OF_MONTH)).show()
        }
    }

    fun setText(text: String) {
        this.text = text
    }

    private fun Date.formatDate(): String{
        val myFormat = "yyyy-MM-dd"
        val simpleDateFormat = SimpleDateFormat(myFormat, Locale.US)
        return simpleDateFormat.format(this).toString()
    }

    override fun onDateSet(view: DatePicker?, year: Int, month: Int, dayOfMonth: Int) {
        myCalendar[Calendar.YEAR] = year
        myCalendar[Calendar.MONTH] = month
        myCalendar[Calendar.DAY_OF_MONTH] = dayOfMonth
        dateSetListener?.onDateSet(myCalendar.time.formatDate())
    }

}

interface DateSetListener{
    fun onDateSet(value: String)
}
hoangngv commented 3 years ago

Here is a sample to help you implement an android component.

First let's define our component, in this case a DatePicker:

@RegisterWidget
class DatePicker(
    val date: Bind<String>,
    val onChange: List<Action>
) : WidgetView() {

    override fun buildView(rootView: RootView) = DatePickerComponent(rootView.getContext()).apply {

        observeBindChanges(rootView, this, date) { text ->
            text?.let { setText(it) }
        }

        dateSetListener = object : DateSetListener {
            override fun onDateSet(value: String) {
                this@DatePicker.handleEvent(
                    rootView,
                    this@apply,
                    onChange,
                    ContextData(
                        id = "onChange",
                        value = value
                    )
                )
            }
        }
    }
}

The implementation will use the native Android DatePicker view:

class DatePickerComponent constructor(
    context: Context
) : TextView(context), DatePickerDialog.OnDateSetListener {

    private val myCalendar: Calendar = Calendar.getInstance()
    var dateSetListener: DateSetListener? = null

    init {
        this.setOnClickListener {
            DatePickerDialog(context, this, myCalendar
                .get(Calendar.YEAR), myCalendar.get(Calendar.MONTH),
                myCalendar.get(Calendar.DAY_OF_MONTH)).show()
        }
    }

    fun setText(text: String) {
        this.text = text
    }

    private fun Date.formatDate(): String{
        val myFormat = "yyyy-MM-dd"
        val simpleDateFormat = SimpleDateFormat(myFormat, Locale.US)
        return simpleDateFormat.format(this).toString()
    }

    override fun onDateSet(view: DatePicker?, year: Int, month: Int, dayOfMonth: Int) {
        myCalendar[Calendar.YEAR] = year
        myCalendar[Calendar.MONTH] = month
        myCalendar[Calendar.DAY_OF_MONTH] = dayOfMonth
        dateSetListener?.onDateSet(myCalendar.time.formatDate())
    }

}

interface DateSetListener{
    fun onDateSet(value: String)
}

Thank you @luisoliveirazup. It works like a charm :D