liveview-native / liveview-client-swiftui

MIT License
381 stars 40 forks source link

Proposal on modifiers #1132

Closed bcardarella closed 11 months ago

bcardarella commented 1 year ago

Using the following class name syntax:

<function1>(<value1> <value2>){<contentName>} <functionN>(<valueN>){<contentName>}

we can call any modifier that we map to match.

<Text :modifiers="font(.largeTitle) bold(true) italic(true)">Hello!</Text>

This would be the same in SwiftUI as:

Text("Hello")
  .font(.largeTitle)
  .bold(true)
  .italic(true)

While this isn't quite Swift code it is very close. We'd need to parse and map to the correct Swift function so we're not dynamically executing code or breaking the remote code execution policy. Only the functions we include in the map are called. If something doesn't exist we should warn to the logger and ignore the entire function call.

The parser on this could be written to be quite fast and an order of N. If the string is parsed from left to write and an AST is built

"font(.largeTitle) bold italic"

would get parsed as [["font", [.largeTitle], nil],["bold", [true], nil],["italic", [true], nil]]

the tuple represents

[<function>, <argsList>, <contentName>]

argList must always be an array of values.

If when looking up the function and the number of arguments supplied doesn't match what is accepted the className should ignored but a warning thrown explaining why.

class name format

<className1> <className2>

className must be a <function> from the Argument Value Forms below.

Argument value forms

  1. number - if the value is a valid number . We should support negative numbers -10 and floats 0.5. This is represented in the tuple as a single value 123
  2. implicit member expression - if the value starts with a . for example .red now becomes the Swift implicit member expression .red. Chaining should be permitted as well.
  3. bool - if the value is "true" or "false" these should convert to true and false. This is represented in the tuple as a single value: true
  4. nil - if the value is "nil" convert to nil this is represented in the tuple as a single value nil
  5. function call - if a <function> is immediately followed by an open hyphen then it will parse until the closing hyphen for white space separated arugments. If the value contains a function call it should ensure white space within the function call isn't parsed as the next argument this is represented in the tuple as its own tuple [<functionName>, <argList>, <contentName>] <functionName> must have a string value. <argList> must be a list of zero - N values in an array [ ... ] and each value is itself in an argument value tuple form. <contentName> is either nil or a String.
  6. key/value - if <key>:<value> is matched parse value as its argument value form. No whitespace is permitted between the <key> and the : not between the : and the <value>. This is represented in the tuple as [<key>, <value>]. <key> must be a String. <value> must follow the argument value form tuple.
  7. text - if all other forms fail to match assume text. This is represented as a single "..." value in the tuple

whitespace characters indicate next argument value.

<function> must always follow the casing of the actual modifers. For example, for allowsTightening:

<Text class="alowsTightening(true) font(.largeTitle) bold italic>Hello!</Text>

For this swiftUI example:

Text("ABCDEF")
    .background(alignment: .leading) { Star(color: .red) }
    .background(alignment: .center) { Star(color: .green) }
    .background(alignment: .trailing) { Star(color: .blue) }

we could represent this as

<Text class="background(alignment:.leading){StarRed} background(alignment:.leading){StarGreen} background(alignment:.leading){StarBlue}>
ABCDEF
    <StarRed>
        <Star class="color-red"/>
    </StarRed>
    <StarGreen>
        <Star class="color-green"/>
    </StarGreen>
    <StarBlue>
        <Star class="color-blue"/>
    <StarBlue>
</Text>

We'd probably want a way to throw an error if <contentName> matched an existing SwiftUI view name. This would lead to an error where if this were done:

<VStack class="background{Text}">
   <Text>
      <Text>This will render</Text>
   </Text>
   <Text>This won't</Text>
</VStack>

int this example because contentName was set to Text is correctly removed the first matched Text element but also removed the second Text element from the tree. If a different name was chosen such as:

<VStack class="background{MoreText}">
   <MoreText>
      <Text>This will render</Text>
   </MoreText>
   <Text>This will also now render</Text>
</VStack>

But because int he client we already have a map of all LVN views through our Registry we can just look up if <contentName> is already defined as a LVN view then error if it is.

This is a very verbose format but I believe it will meet our needs to get the diff/patch benefits of LiveView for the modifiers which currently it doesn't. It also avoids the serialization overhead. It introducers a verbose but logical syntax that anybody can look up a modifiers and know how to call it with class names.

This should allow for faster modifier parsing. It should also allow for cleaning diff pathing in LiveVie itself. This could lead to

bcardarella commented 1 year ago

I believe this covers nearly everything.

bcardarella commented 1 year ago

So after sleeping on this I think using this format to create a DSL for the modifiers is the way to go. This puts things on the path of a css-like rule set that can then be matched with class names.

One thing I realized that would be an issue with modclass/2 is the sheet volume of duplicate function calls. We ran into this on Beacon. The memory footprint of the in-memory compiled modules explodes when there are a few hundred overloaded functions in Elixir. Considering we want to support a tailwind-like styling framework and tailwind itself in full is close to 20MBs I don't think we'd be able to do that with a single function call. I think we may need to define a DSL that allows for styling. Here is a 1st pass:

style(%Native{platform: :swiftui}, "color-" <> color_name) do
  "color(#{color_name})"
end

for styles that bundle a few modifiers together:

style(%Native{platform: :swiftui}, "highlight-" <> color_name) do
   [
      "bold(true)",
      "italic(true)",
      "color(#{color_name})"
   ]
end

this would require a caller to style that matched like this:

native
|> style(class_name)
|> List.wrap()

to ensure that all modifiers are forced to a list then the resulting lists are concatenated for the client

bcardarella commented 1 year ago

Yes I realize I'm advocating for re-creating CSS. However, we've struggled with finding a rule set that will work for modifiers. I really like the style of app development that came about from @supernintendo 's modclass work but the performance issues of serialization overhead from how we're currently managing modifiers is a non-starter for me. This proposal allows us the best of both worlds and creates a path for UXDevs to contribute and build apps in the familiar setting they are used to

josevalim commented 1 year ago

Should you then just bite the bullet and allow it to be Swift syntax with support for interpolated blocks (which I would use the slot syntax for).

<Text class=".background(alignment: .leading){:starred}.background(alignment: .leading){:stargreen}. background(alignment:.leading){:starblue}">

This may actually make it harder to parse though.

bcardarella commented 1 year ago

@josevalim I considered that but Apple has a policy on remote code execution. And despite this not being that as we'd have to do map each tuple to the function definition on the client I don't know where the line is that Apple will say "no". I'm open to exploring that though

bcardarella commented 1 year ago

As for the parsing, right now the deliminator is whitespace but I could see us using ; like CSS to make the parsing easier/cleaner as long as the ; isn't a special character in Swift that I'm unaware of

carson-katri commented 1 year ago

Parsing isn’t the issue so much as diffing. If we stuff everything in a string we’re back where we started. Whether it’s JSON or a custom format, we still have to parse every modifier in the stack even if just one changes. The benefit of modifiers as elements is we can cleanly diff.

josevalim commented 1 year ago

@carson-katri do you mean diffing on the client or on the server? LiveView optimizes this <div foobar={"bar #{baz}"}> so only baz is resent and if some cases are missing, we could support that too.

bcardarella commented 1 year ago

@carson-katri that's why I think ultimately the longer-term solution is to use class names that will apply the rules. In web LiveView we'd have a similar problem if styles were applied in-line in the element itself. For example:

<div style="text-alignment: center">Foo Bar</div>

pretty much puts the LiveView diffing in the same boat as the LiveView Native diffing we've been trying but when it moves over to:

<div class="text-center">Foo Bar</div>

we get the diff/patch benefits

josevalim commented 1 year ago

Btw, I have been meaning to ask, is this an issue on React Native? If so, how do they solve it?

carson-katri commented 1 year ago

Ok, then we'd have to make changes to core to communicate which parts of the attribute changed.

bcardarella commented 1 year ago

@josevalim React Native has a style attribute: https://reactnative.dev/docs/style and I believe they're ultimately doing something very similar to html/css

supernintendo commented 1 year ago

@josevalim As far as I understand, React Native doesn't use modifiers as it predates SwiftUI. React Native actually has its own custom styling language as it tries to abstract most components into its own custom cross-platform components that translate to each platform's native components. LiveView Native treats each platform as its own, and attempts to map to the native platform's API.

bcardarella commented 1 year ago

@supernintendo @josevalim this is correct, React Native is implemented with ObjC. It doesn't use any Swift/SwiftUI as it defined its own rendering engine that has more in common with the browser and DOM than anything else.

josevalim commented 1 year ago

I see, thanks. :heart: Maybe the best option is to actually move modclass to the client, which is how Tailwind works too (you add new tailwind classes by changing JS). This way...

  1. LVN defines common modifiers for the most common needs

  2. A developer can easily define additional modifiers on the Swift client using LVN extension points

So we have:

<Text class="font-largeTitle bold italic color-red">Hello!</Text>

If I want to use a custom SwiftUI extension, I can register new modifiers on Swift. The downside is that people need to write a bit of Swift code but:

For blocks, as in backgrounds, you can use slots, and you provide APIs for people implementing their own modifiers/classes to retrieve the slot and apply it as a block.

WDYT?

supernintendo commented 1 year ago

@bcardarella I feel like we're trying really hard to fit Swift syntax into EEx templates cleanly and it just isn't working. Even with your DSL example, I think there will still be a lot of overhead on the client to deserialize that stringified representation of the modifiers, parse it into an AST and then traverse it to apply the modifiers. That problem is compounded once diffing into account as @carson-katri suggests.

I agree with @josevalim, that if we're going to try to represent the modifiers with Swift syntax we should just represent them as class names from within the template and then have the app developer handle them from Swift. It does require learning a bit of Swift, but having built a few LVN apps already, I can say that the current modifiers implementation already forces you to do that. You eventually hit a limit where the LVN-represented modifiers are not enough and you have to resort to the custom registry.

bcardarella commented 1 year ago

@josevalim this is kind of where I've landed but I defined the rules engine as we need to be able to also account for the sub-templates:

Text("ABCDEF")
    .background(alignment: .leading) { Star(color: .red) }
    .background(alignment: .center) { Star(color: .green) }
    .background(alignment: .trailing) { Star(color: .blue) }

if we are delegating everything to Swift then the { Star(color: red) } wouldn't be something we could do in LVN. My format allows for a named content element that we can then match to an element name

carson-katri commented 1 year ago

I think you could represent that as bg-leading-star.

We could establish whatever ordering we want here, or perhaps allow certain classes to be chained?

And let the modifier on the client lookup the slot with the name star.

<Text :modclass="bg-leading-star">
  <:star>
    <Star color=red />
  </:star>
</Text>
bcardarella commented 1 year ago

@carson-katri what about:

<Text :modclass="bg-leading:star">
  <:star>
    <Star color=red />
  </:star>
</Text>

this way the syntax implies a content with the :

carson-katri commented 1 year ago

Yeah, that would work too. Some modifiers have several arguments, like sheet, so we’d have to figure out how those can be represented.

josevalim commented 1 year ago

I like using : as it matches slots, but keep in mind it has a different meaning than in Tailwind (which is fine, I am mentioning for completeness). :)

Something else you can consider is supporting arbitrary properties. For example, let's imagine there is no utility for background, you could write your code above as (using your suggestion for blocks):

<Text class="
    background[alignment:leading]:red-star
    background[alignment:center]:green-star
    background[alignment:trailing]:blue-star
">
  <:red-star><Star class="color-red" /></:red-star>
  <:green-star><Star class="color-green" /></:green-star>
  <:blue-star><Star class="color-blue" /></:blue-star>
</Text>
bcardarella commented 1 year ago

As far as color=red I don't think that's the way to go. It would be <Star class="color-red" /> I also think we can just use class at this point because there wouldn't be anything happening to that attr or string server side

josevalim commented 1 year ago

So to sum up:

EDIT: I remove my proposal for custom properties. It is best to start small (classes + slots/blocks) and then add more features later.

supernintendo commented 1 year ago

@bcardarella How should we go about moving forward with these changes? Will Elixir-based modifiers be on a gradual deprecation path or will we do a hard swap for the next minor / major version release?

bcardarella commented 1 year ago

@supernintendo that's a good question, I don't mind the hard swap as we really don't yet have a lot of people using. I definitely want to get this done prior to v0.2.0 but I don't want to play modifier-whack-a-mole either. I think we need to define acceptance criteria. For me this change needs to meet the following

  1. works with LiveView's diff/patch programming model for performance and low overhead
  2. completely encapsulates the SwiftUI modifiers, we shouldn't be punting on a certain modifier type just because it is hard to represent
  3. has a good and familiar dev ergonomics in the template that UX developers can work with
carson-katri commented 1 year ago

Here is one option for something like the sheet modifier (which has a binding argument isPresented in SwiftUI). I structured it like Tailwind's content class, with a custom value being passed in square brackets at the end.

The class would be written as: sheet-(presented|dismissed)-[event_name]:slot

Here it is in use:

<Button phx-click="open_sheet" class={"sheet-#{if @presented, do: "presented", else: "dismissed"}-[sheet_changed]:content"}>
  <:content>
    <Text>Modal content</Text>
  </:content>
</Button>
def handle_event("open_sheet", _, _)
def handle_event("sheet_changed", _, _)

This is a case where diffing may break down, since the interpolation happens for just part of the class. You could instead write it as if @presented, do: "sheet-presented-[sheet_changed]:content", else: "sheet-dismissed-[sheet_changed]:content" to ensure the entire class is swapped out. However, I think the classes are inherently lighter-weight than JSON and can use simpler pattern matching in Swift instead of JSON decoding which could speed things up.

josevalim commented 1 year ago

Is the goal to use [...] to denote events? Could it be sheet-presented-sheet_changed:content instead?

bcardarella commented 1 year ago

should we really be defining events in the class? It seems like it'll get messy having actual server side events tied to class names. Wouldn't it be most clean to leave the class names simply for client side styling? Then the phx- handlers for anything that need to be handled by LV server side?

carson-katri commented 1 year ago

The modifier has a binding, which communicates to the server when the sheet has been dismissed by the user. We need to come up with a syntax for that to satisfy requirement 2. Another option could be to put the event on the slot.

<Button phx-click="open_sheet" class={"sheet-#{if @presented, do: "presented", else: "dismissed"}:content"}>
  <:content phx-change="sheet_changed">
    <Text>Modal content</Text>
  </:content>
</Button>
carson-katri commented 1 year ago

I guess the issue becomes that not all modifiers in SwiftUI are for styling. There are many functional modifiers as well (such as for modal presentations, keyboard shortcuts, etc.).

bcardarella commented 1 year ago

@carson-katri in this example wouldn't it be better to just use a heex conditional to render <Text>Modal content</Text> ? Instead of the SwiftUI sheet?

bcardarella commented 1 year ago

so something like this:

<Button phx-click="open_sheet">
   <% if @presented %>
      <Text>Modal content</Text>
   <% end %>
</Button>

then we have the value of @presented being updated with a sever side evet

carson-katri commented 1 year ago

A sheet has a specific UI, it pops up from the bottom and covers the screen. Think the compose email UI in the system Mail app. It can also be configured with detents like in Maps, which has a sheet that is persistently at the bottom of the map and can be dragged up and down.

There are also modifiers for alerts, confirmation dialogs, fullscreen modals, etc. https://developer.apple.com/documentation/swiftui/modal-presentations

bcardarella commented 1 year ago

I still don't see a reason to have a phx-change event on the slot. If the phx-click handler is toggling the value of presented and this updated the class name why wouldn't SwiftUI be able to merge this state into its own viewtree?

carson-katri commented 1 year ago

If the user of the app swipes down on the sheet, it is dismissed (this is the default behavior for sheets on iOS). We need an event to communicate this change to the server so it can keep its assigns up to date.

josevalim commented 1 year ago

@carson-katri the modals are defined as properties/modifiers in the element that triggers them?!

In any case, maybe this is better done with a slot? And is the server communicated to at all when the modal opens? In LV, we do it with JS commands, so we don't need to go for the server. Maybe?

<Button>
   <:sheet phx-dismiss="...">
      <Text>Modal content</Text>
   </:sheet>
</Button>
bcardarella commented 1 year ago

@carson-katri ah ok, so those UIs have an action... thanks for that context this makes more sense now.

@josevalim yes, this is what we've been struggling with :D It seems Apple just shoved so much stuff into "modifiers" context

carson-katri commented 1 year ago

Yes, all modals are regular modifiers. You can put them on any element in your hierarchy (usually they are put on the outer-most View or the element that triggers them). When you update the isPresented binding they are displayed. When the user closes them, the isPresented binding is set to false, and the onDismiss callback is called. Our current implementation uses two arguments, is_presented and change (which takes an event name).

Here are the docs for sheet: https://developer.apple.com/documentation/swiftui/view/sheet(ispresented:ondismiss:content:)

A slot would work, but then we would have two ways of writing modifiers (which might be fine if used sparingly 🤷‍♂️ ). I do agree that putting the event name in the class has its limitations, for example we can't pass debounce/throttle.

josevalim commented 1 year ago

@carson-katri another option is to break away from modifiers and introduce an explicit present="#foo" attribute, which identifies a separately defined sheet. But that requires a notion of IDs, which I am not sure you have. :S

carson-katri commented 1 year ago

We have the concept of IDs, so that could work. We also have to consider that some of these modals have other named content/attributes within them. For example, confirmationDialog has a string title argument. alert has a title string argument, and message and actions blocks which can take more SwiftUI Views.

<!-- `confirmationDialog` modifier (known as an "action sheet" in UIKit) -->
<Button phx-click="toggle_is_presented">
  <:confirmation-dialog is-presented={@is_presented} title="Choose an option" title-visibility="automatic">
    <Button>Option 1</Button>
    <Button>Option 2</Button>
    <Button>Option 3</Button>
  </:confirmation-dialog>
</Button>

<!-- `alert` modifier -->
<Button phx-click="toggle_is_presented">
  <:alert is-presented={@is_presented} title="Choose an option">
    <:message>
      <Text>There are 3 choices</Text>
    </:message>
    <:actions>
      <Button>Option 1</Button>
      <Button>Option 2</Button>
      <Button>Option 3</Button>
    </:actions>
  </:alert>
</Button>
josevalim commented 1 year ago

Keep in mind it is not possible to have slots inside slots, so that could be a push towards making them elements and bindings them via an ID. It would also feel a bit more HTML-ish.

supernintendo commented 1 year ago

@josevalim That's a good point. In SwiftUI, you could have the content of a modifier include a view that has a modifier which takes content. The slots syntax wouldn't work with like a fullScreenCover that displays a modal containing swipeActions.

bcardarella commented 1 year ago

This is why my original rule syntax accounted for this by having the name/slot as {contentName} and then within that slot the actual content itself would have its own modifiers.

bcardarella commented 1 year ago

@carson-katri maybe it would be helpful if we mapped out the full set of responsibilities of modifiers:

is everything here correct? Am I missing anything?

josevalim commented 1 year ago

It seems that HTML-like is not going to be enough to map all features in a language like Swift, so to me it looks like the decision is if we want to have a general API for modifiers (like @bcardarella's initial proposal) or if you are fine with picking different solutions for different problems. The former can start to fill very alien as it mirrors Swift. The latter requires much more work though but it at least makes sense to me: modals and gestures likely require different solutions.

carson-katri commented 1 year ago

Apple's documentation divides modifiers across these categories/sub-categories:

carson-katri commented 1 year ago

I wanted to explore some of our options for converting specific complex modifiers. searchable is a good example of a complex modifier. It adds a system search bar to the UI.

It has 5 arguments:

Here are 4 ways we could accomplish a modifier with this level of complexity.

  1. Custom elements

An element is used instead of a class. However, we lose some ordering here between classes and element modifiers. What if I want the searchable to be in the middle of the modifier stack?

This isn't a huge problem for this particular modifier, but could present a problem for others.

<List>
    <searchable text={@query} tokens={@tokens} phx-change="search-field-changed" placement="automatic" prompt="Type here...">
        <Text :for={token <- @all_token_types} id={token}><%= token %></Text>
    </searchable>
</List>
  1. Class + slot with attributes

This requires both a class and a slot. The slot holds the complex attributes of the modifier. Ordering is not a problem here, because the modifier is still represented in the class list. One issue with this approach is that the modifier is separated from its arguments, so you need to scroll around to find it. We could solve this with good tooling (cmd+click on class to reveal the slot, for example).

<List class="searchable:my-search-field"> <!-- This sets up a `searchable` modifier -->
    <:my-search-field text={@query} tokens={@tokens} phx-change="search-field-changed" placement="automatic" prompt="Type here...">
        <Text :for={token <- @all_token_types} id={token}><%= token %></Text>
    </:my-search-field>
</List>

We could also choose to move specific arguments into the class name. For example, placement could easily be included in the class name as searchable-automatic, searchable-sidebar, searchable-toolbar, etc.

  1. Class with complex arguments

Classes could be expanded to accept much more complex arguments. Space characters become an issue, Tailwind works around this by converting _ to space in many cases. This requires much more complex parsing logic.

<List class={"searchable[text:'#{@query}',tokens:#{@tokens},change:search-field-changed,placement:automatic,prompt:'Type_here...']:content"}>
    <:content>
        <Text :for={token <- @all_token_types} id={token}><%= token %></Text>
    </:content>
</List>
  1. Separate search API

We could invent our own API completely separate from SwiftUI for searchable. For example, we could add a set of attributes available on any element for adding a search bar.

<List searchable={@query} phx-change-search="search-field-changed" searchable-prompt="Type here..." ...>
</List>

My personal choice would be option 2. It will be efficient to parse and does not deviate too much from a standard modifier class.

bcardarella commented 1 year ago

So it is quite obvious that some of the responsibilities of modifiers simply cannot be represented in a class or a rule. It would be a daunting task but it may ultimately be best if we break those up on our end. For example, in @carson-katri 's latest example the prompt value clearly should be an element attr.

supernintendo commented 1 year ago

@carson-katri I like your idea for class + slot with attributes but its worth reiterating @josevalim's point that slots can't be nested within slots. So I'm still not sure that would work with certain examples, like the one I mentioned here.

bcardarella commented 1 year ago

But slots within LV components are functional. In our case they're essentially an annotation. Because they're used within markup why would there need to be a limit imposed upon nested slots?