ZupIT/beagle

How to tell when other component scroll into view?

Closed this issue · 13 comments

Use case

I want to do something to a component when other component scroll into view. They live in the same container parent.
How can I do that?

Proposal

  • Add an event like system to the JSON. Also let ListView (or GridView) send out that to each children.
  • Maybe write the data to the context (similar to implicit context added in 2.1)

Hello @brian-chu-twc

I don't think I understand exactly what you're trying to do. Can you please give us a practical example?

Say I have this screen:

┌────────────────────────────────────┐
│                                    │
│  ┌──────────────────────────────┐  │
│  │                              │  │
│  │                              │  │
│  │                              │  │
│  │                              │  │
│  │              A               │  │
│  │                              │  │
│  │                              │  │
│  │                              │  │
│  │                              │  │
│  │                              │  │
│  └──────────────────────────────┘  │
│  ┌──────────────────────────────┐  │
│  │                              │  │
│  │                              │  │
│  │                              │  │
│  │                              │  │
│  │         B                    │  │
│  │                              │  │
│  │                              │  │
│  │                              │  │
│  │                              │  │
│  └──────────────────────────────┘  │
│  ┌──────────────────────────────┐  │
│  │                              │  │
│  │                              │  │
│  │                              │  │
│  │           C                  │  │
│  │                              │  │
│  │                              │  │
│  │                              │  │
│  │                              │  │
│  │                              │  │
│  └──────────────────────────────┘  │
│                                    │
│                 ┌──────────────────┤
│                 │                  │
│                 │        X         │
│                 │                  │
│                 │                  │
│                 │                  │
└─────────────────┴──────────────────┘


They (A, B, C) live in a scroll view, X is anchor on the bottom, when A is scroll off the screen, X need to be disappear (or do some actions).

I manage to make my custom component (via modifier's onGloballyPositioned in Compose) to be aware of the location change and send it out.
However, there is absolute no way for me to do it if it's a component provided by the framework (e.g. container) cause I only take the framework as binary distribution so I can't modify the framework source.

Brian would it solve your problem if we had an event in the ListView that is triggered when an item comes into or leaves the viewport?

Here's an example:

{
    "_beagleComponent_": "beagle:container",
    "context": {
        "id": "shouldShowX",
        "value": true
    },
    "children": [
        {
            "_beagleComponent_": "beagle:listView",
            "dataSource": "myDataSource",
            "onItemVisibilityChange": [
              {
                "_beagleAction_": "beagle:condition",
                "condition": "@{and(eq(item.id, 'A'), not(isVisible))}",
                "onTrue": [
                  {
                    "_beagleAction_": "beagle:setContext",
                    "id": "shouldShowX",
                    "value": false
                  }
                ]
              }
            ]
        },
        {
            "_beagleComponent_": "custom:bottomFixed",
            "shouldShow": "@{shouldShowX}"
        }
    ]
}

At first, the component "custom:bottomFixed" is gonna have "shouldShow" set to true. When a component leaves the view port, the event "onItemVisibilityChange" will be triggered with the implicit contexts "item" and "isVisible". "item" is a reference to the item that entered or left the view port. "isVisible" is a boolean indicating whether the item entered or left the view port, if it entered (became visible), the value is true, if it left (became invisible), the value is false. With a conditional action we check if the item is "A" and if it left the view port. If the condition is true, we change the value of "shouldShow" to false. This way, whenever "A" leaves the view port, "custom:bottomFixed" has its property "shouldShow" set to false.

I can't promise this will be implemented, we have to check if this is possible given the current implementation of the ListView. But if this would solve your problem, we'll take a look into it and, depending on what we find, add it to the backlog.

It is an excellent proposal, hope to see it becomes reality.
My case actually is scrollview, it may be a little bit different story.

In the case of a ScrollView, it might not be very hard to implement this as a custom component. I'll check with @hernandazevedozup and @dantes-git if we can make an example.

@dantes-git came up with much better idea that should work for both the ScrollView and ListView without any changes to Beagle!

The idea is to, instead of attaching these events to the ListView or ScrollView, attach them to the component you want to observe the visibility of. Both iOS and Android have APIs for observing when a view becomes visible or invisible, so it's possible to implement a custom component with such behavior.

This custom component could be a Container with the properties onShow and onHide. In the example below we called this component custom:visibilityWidget. The next JSON creates a screen very similar to the one you showed in your example: the first component in the scroll view is a custom:visibilityWidget that changes the value of the variable context.footerDisplay whenever it becomes visible (FLEX) or invisible (NONE). context.footerDisplay controls if the last component in the hierarchy (a floating container) is displayed or not with style.display. style.display is not an animated property, but you can create a custom component with your custom animations using the same idea.

{
  "_beagleComponent_": "beagle:container",
  "context": {
    "id": "context",
    "value": {
      "footerDisplay": "FLEX"
    }
  },
  "children": [
    {
      "_beagleComponent_": "beagle:scrollView",
      "children": [
        {
          "_beagleComponent_": "custom:visibilityWidget",
          "onShow": [
            {
              "_beagleAction_": "beagle:setContext",
              "contextId": "context",
              "path": "footerDisplay",
              "value": "FLEX"
            }
          ],
          "onHide": [
            {
              "_beagleAction_": "beagle:setContext",
              "contextId": "context",
              "path": "footerDisplay",
              "value": "NONE"
            }
          ],
          "child": {
            "_beagleComponent_": "beagle:container",
            "style": {
              "backgroundColor": "#0000FF50",
              "size": {
                "height": {
                  "type": "REAL",
                  "value": 300
                }
              }
            },
            "children": [
              {
                "_beagleComponent_": "beagle:text",
                "text": "D"
              }
            ]
          }
        },
        {
          "_beagleComponent_": "beagle:container",
          "style": {
            "backgroundColor": "#00FF0050",
            "size": {
              "height": {
                "type": "REAL",
                "value": 300
              }
            }
          },
          "children": [
            {
              "_beagleComponent_": "beagle:text",
              "text": "B"
            }
          ]
        },
        {
          "_beagleComponent_": "beagle:container",
          "style": {
            "backgroundColor": "#FF000050",
            "size": {
              "height": {
                "type": "REAL",
                "value": 300
              }
            }
          },
          "children": [
            {
              "_beagleComponent_": "beagle:text",
              "text": "C"
            }
          ]
        },
        {
          "_beagleComponent_": "beagle:container",
          "style": {
            "backgroundColor": "#00FF0050",
            "size": {
              "height": {
                "type": "REAL",
                "value": 300
              }
            }
          },
          "children": [
            {
              "_beagleComponent_": "beagle:text",
              "text": "C"
            }
          ]
        },
        {
          "_beagleComponent_": "beagle:container",
          "style": {
            "backgroundColor": "#00FF0050",
            "size": {
              "height": {
                "type": "REAL",
                "value": 300
              }
            }
          },
          "children": [
            {
              "_beagleComponent_": "beagle:text",
              "text": "E"
            }
          ]
        }
      ]
    },
    {
      "_beagleComponent_": "beagle:container",
      "style": {
        "backgroundColor": "#00FF0050",
        "display": "@{context.footerDisplay}",
        "size": {
          "height": {
            "type": "REAL",
            "value": 50
          }
        },
        "positionType": "ABSOLUTE",
        "position": {
          "bottom": {
            "type": "REAL",
            "value": 0
          },
          "right": {
            "type": "REAL",
            "value": 0
          }
        }
      },
      "children": [
        {
          "_beagleComponent_": "beagle:text",
          "text": "Footer"
        }
      ]
    }
  ]
}

@dantes-git and @hernandazevedozup will soon post examples for implementing custom:visibilityWidget on iOS and Android, respectively.

Below is the example on iOS. In this example the solution to check if a view is perhaps a little complex, there may be other simpler solutions to this problem.

struct VisibilityWidget: Widget {
    var id: String?
    var style: Beagle.Style?
    var accessibility: Beagle.Accessibility?

    @AutoCodable
    var child: ServerDrivenComponent

    @AutoCodable
    var onShow: [Action]?

    @AutoCodable
    var onHide: [Action]?

    func toView(renderer: BeagleRenderer) -> UIView {
        let view = renderer.render(child)
        return VisibilityView(child: view, onShow: onShow, onHide: onHide, controller: renderer.controller)
    }
}

class VisibilityView: UIView {
    var child: UIView

    var onShow: [Action]?
    var onHide: [Action]?

    weak var controller: BeagleController?

    var isShowing: Bool = true {
        didSet {
            if oldValue != isShowing {
                DispatchQueue.main.async {
                    if self.isShowing {
                        self.controller?.execute(actions: self.onShow, event: "onShow", origin: self.child)
                    } else {
                        self.controller?.execute(actions: self.onHide, event: "onHide", origin: self.child)
                    }
                }
            }
        }
    }

    init(child: UIView, onShow: [Action]?, onHide: [Action]?, controller: BeagleController?) {
        self.child = child
        self.onShow = onShow
        self.onHide = onHide
        self.controller = controller
        super.init(frame: .zero)

        addSubview(child)
        yoga.isEnabled = true
    }

    override func didMoveToWindow() {
        configureScrollDelegate()
    }

    func configureScrollDelegate() {
        var current = superview
        while current != nil {
            if let scroll = current as? UIScrollView {
                scroll.delegate = self
                break
            }
            current = current?.superview
        }
    }

    override func layoutSubviews() {
        checkIfIsShowing()
    }

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

    func checkIfIsShowing() {
        func isShowing(view: UIView) -> Bool {
            func isShowing(view: UIView, inView: UIView?) -> Bool {
                guard let inView = inView else { return true }
                let viewFrame = inView.convert(view.bounds, from: view)
                if viewFrame.intersects(inView.bounds) {
                    return isShowing(view: view, inView: inView.superview)
                }
                return false
            }
            return isShowing(view: view, inView: view.superview)
        }
        self.isShowing = isShowing(view: child)
    }

}

extension VisibilityView: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        checkIfIsShowing()
    }
}
Simulator.Screen.Recording.-.iPhone.11.-.iOS.13.5.-.2022-12-20.at.11.41.48.mp4

Here is the implementation for this example on Android:

/**
 * For this widget to work please create a ids.xml file under values folder with the following content
    <resources>
    <item name="beagle_component_tag_visibility" type="id" />
    </resources>
 */
@RegisterWidget(name = "visibilityWidget")
data class VisibilityWidget(
    val onShow: List<Action>,
    val onHide: List<Action>,
    val child: ServerDrivenComponent,
) : WidgetView() {

    override fun buildView(rootView: RootView): View {
        return (child as ViewConvertable).buildView(rootView).apply {

            this.setVisibilityTag(false)
            this.viewTreeObserver.addOnDrawListener {
                val rect = Rect()
                //Check if at least a piece of the view is visible
                val visible = (this.getGlobalVisibleRect(rect)
                    &&  rect.height() > 0 && rect.width() > 0)

                if (this.getVisibilityTag() != visible) {
                    this.setVisibilityTag(visible)
                    if (visible) {
                        handleEvent(rootView, this, onShow, analyticsValue = "onShow")
                    } else {
                        handleEvent(rootView, this, onHide, analyticsValue = "onHide")
                    }
                }
            }
        }
    }
}

internal fun View.setVisibilityTag(visibility: Boolean) {
    setTag(R.id.beagle_component_tag_visibility, visibility)
}

internal fun View.getVisibilityTag(): Boolean {
    @Suppress("UNCHECKED_CAST")
    return getTag(R.id.beagle_component_tag_visibility) as Boolean? ?: false
}

That's more or less I did to my custom component currently. BUT I can't apply it to build-in Container class since they're protected. I can kind of create a similar container class with this change and put that in everywhere. but when some one write JSON just using a container, (e.g. container with a text in it), it doesn't work.

I still like the idea of make onItemVisibilityChange available in the JSON level.

Hi Brian. Having custom components should be a part of the experience of using Beagle, it should not be considered a problem. Beagle can't attempt to cover every possible scenario with its default components, that's why it encourages the developers to create their own components with custom behavior. We expect most applications to use much more custom components than default components.

When the developers don't find what they're looking for in the default components, Beagle should make it easy for them to implement their own components. Unfortunately, it fails to do so with the ListView and GridView, which are too complex for us to expect the user to implement their version of it. This is why whenever something is requested for the Lists, we'll consider doing it. For every other component, if we find a solution using custom components, this is what we're going to recommend.

Unless you don't have control over the backend application, I can't see why replacing beagle:container by something like custom: visibilityWidget would be a problem.

In any case, we can consider making it possible to replace a default component with a custom component, so when the json says beagle:container, Beagle loads your component instead of the default. Would this help?

Understood

In my case, I do can control the back-end.

I am not award of the design principle of custom components vs default components you pointed out. Now that you state this way, it makes more sense to create my own components, instead of relying on adding (or patching) bulit-in components to suit everyone needs.