Closed bcardarella closed 11 months ago
I believe this covers nearly everything.
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
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
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.
@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
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
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.
@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.
@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
Btw, I have been meaning to ask, is this an issue on React Native? If so, how do they solve it?
Ok, then we'd have to make changes to core to communicate which parts of the attribute changed.
@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
@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.
@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.
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...
LVN defines common modifiers for the most common needs
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?
@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.
@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
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>
@carson-katri what about:
<Text :modclass="bg-leading:star">
<:star>
<Star color=red />
</:star>
</Text>
this way the syntax implies a content with the :
Yeah, that would work too. Some modifiers have several arguments, like sheet
, so we’d have to figure out how those can be represented.
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>
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
So to sum up:
class is a list of classes separated by whitespace
using :
means a slot will be passed to the class
color-red:foobar
, where color-red
clearly does not use the slot?~using [key:value]
means properties will be passed to the class~
color-red[foo:bar]
is given, where color-red
clearly does not use the properties?[...]
is only allowed with raw modifiers (i.e. background instead of bg)? Perhaps it is best to not allow raw modifiers at all. A class must always be registered. Automatically falling back can be confusing (and be prone to code injection on the client).EDIT: I remove my proposal for custom properties. It is best to start small (classes + slots/blocks) and then add more features later.
@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?
@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
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.
Is the goal to use [...]
to denote events? Could it be sheet-presented-sheet_changed:content
instead?
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?
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>
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.).
@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?
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
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
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?
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.
@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>
@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
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.
@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
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>
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.
@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.
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.
@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?
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.
Apple's documentation divides modifiers across these categories/sub-categories:
buttonStyle
, textFieldStyle
, etc.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:
text
- a two-way binding to a string, similar to a TextField
elementtokens
- a two-way binding to an array of "tokens" (could be represented as strings or atoms). Sort of like filters on the search.placement
- enum property for where the search bar should goprompt
- the placeholder text for the fieldtoken
- a render function that receives a token as an argument, and produces a View for that tokenHere are 4 ways we could accomplish a modifier with this level of complexity.
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>
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.
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>
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.
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.
@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.
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?
Using the following class name syntax:
<function1>(<value1> <value2>){<contentName>} <functionN>(<valueN>){<contentName>}
we can call any modifier that we map to match.
This would be the same in SwiftUI as:
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
-10
and floats0.5
. This is represented in the tuple as a single value123
.
for example.red
now becomes the Swift implicit member expression.red
. Chaining should be permitted as well."true"
or"false"
these should convert totrue
andfalse
. This is represented in the tuple as a single value:true
"nil"
convert tonil
this is represented in the tuple as a single valuenil
<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 eithernil
or a String.<key>:<value>
is matched parsevalue
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."..."
value in the tuplewhitespace characters indicate next argument value.
<function>
must always follow the casing of the actual modifers. For example, forallowsTightening
:For this swiftUI example:
we could represent this as
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:int this example because
contentName
was set toText
is correctly removed the first matchedText
element but also removed the secondText
element from the tree. If a different name was chosen such as: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