Flutter-Bounty-Hunters / swift_ui

Flutter port of Swift UI
MIT License
63 stars 4 forks source link

[Shapes] - Implementing property method alternatives in Flutter #27

Open suragch opened 4 months ago

suragch commented 4 months ago

I'm implementing the a Rectangle widget (#26).

Here is what a Rectangle view in SwiftUI can do:

Screenshot 2024-06-04 at 11 26 58

The swift code for that looks like so:

import SwiftUI

struct RectanglePage: View {
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                // Basic rectangle
                Rectangle()
                    .fill(Color.blue)
                    .frame(width: 200, height: 100)

                // Rectangle with stroke
                Rectangle()
                    .stroke(Color.red, lineWidth: 4)
                    .frame(width: 200, height: 100)

                // Rectangle with rounded corners
                Rectangle()
                    .fill(Color.green)
                    .cornerRadius(20)
                    .frame(width: 200, height: 100)

                // Rectangle with gradient fill
                Rectangle()
                    .fill(LinearGradient(gradient: Gradient(colors: [.yellow, .orange]), startPoint: .leading, endPoint: .trailing))
                    .frame(width: 200, height: 100)

                // Rectangle with shadow
                Rectangle()
                    .fill(Color.purple)
                    .shadow(color: .gray, radius: 10, x: 0, y: 10)
                    .frame(width: 200, height: 100)
            }
        }
    }
}

struct RectanglePage_Previews: PreviewProvider {
    static var previews: some View {
        RectanglePage()
    }
}

In that code you can see a number of different modifier methods:

One of the guiding principles is that swift_ui doesn't use modifier methods.

How to avoid modifier methods gives a hypothetical alternative for Flutter:

Frame(
  width: 200,
  height: 100,
  child: Fill(
    Colors.purple,
    child: Ellipse(),
  ),
);

Is this how we want to define the API, by creating widgets for all of the following?

Not widget properties?

@matthew-carroll

matthew-carroll commented 4 months ago

@suragch to help us answer that question, can you investigate a little more deeply on modifiers like fill(), stroke(), and shadow()?

Probably the most important detail that differentiates a property from a widget is how generally applicable a modifier is meant to be. For example, I don't know that it's even possible for us to implement a generic Stroke widget - a stroke will require knowledge of a given shape to stroke. So that, perhaps, should be a widget property. But we should do that analysis for each item, both how its used in Swift UI, and what kind of limitations we might run into with widget composition.

suragch commented 4 months ago

@matthew-carroll I started out by taking a list of Swift property methods and going through them one by one to see if they should be a widget property or a composable widget. After while I discovered that some of these were extensions on Shape while others were extensions on View. My conclusion is that the Shape extensions should be widget properties while the View extensions have the potential to be composable widgets themselves.

Rather than completely rewrite my original answer, I'll keep the examples, and then move on to what belongs to Shape and View.

fill()

This fills the shape with a color or gradient.

Screenshot 2024-06-05 at 10 52 25
HStack {
    Rectangle()
        .fill(Color.blue)
        .frame(width: 100, height: 100)
    Circle()
        .fill(Color.blue)
        .frame(width: 100, height: 100)
}

fill makes sense for a shape, but it is ambiguous in the generic context. For example, filling Text would imply setting the text color, but filling a VStack might imply setting the background color. I recommend fill() should be a property of the shape.

stroke()

This modifier applies a stroke to the shape with a specified color and line width.

Screenshot 2024-06-05 at 11 04 33
HStack {
    Rectangle()
        .stroke(Color.red, lineWidth: 4)
        .frame(width: 100, height: 100)

    Circle()
        .stroke(Color.green, lineWidth: 2)
        .frame(width: 100, height: 100)
}

stroke() doesn't have a clear meaning when applied to other types of views like Text, Image, or VStack. The concept of stroking is closely tied to the geometry of a shape. I recommend implementing stroke() as a widget property.

strokeBorder()

This property is similar to stroke, but the stroke is drawn inside the shape's border, preserving the shape's size.

Screenshot 2024-06-05 at 11 11 06
HStack {
    Rectangle()
        .stroke(Color.blue, lineWidth: 10)
        .frame(width: 100, height: 100)

    Rectangle()
        .strokeBorder(Color.blue, lineWidth: 10)
        .frame(width: 100, height: 100)
}

For the same reason as stroke this should be a widget property.

trim(from:to:)

This modifier trims a shape by specifying a start and end point, effectively creating a partial shape. The from and to parameters are values between 0 and 1, representing the percentage of the shape to be trimmed.

Screenshot 2024-06-05 at 11 13 38

This modifier is not applicable to other types of views like Text, Image, or VStack because they don't have the concept of a path or a shape that can be trimmed. So trim should be a widget property.

inset(by:)

This modifier insets the content of a shape by a specified amount on all sides.

Screenshot 2024-06-05 at 11 25 13
HStack {
    Rectangle()
        .inset(by: 10)
        .fill(Color.blue)
        .frame(width: 100, height: 100)

    Circle()
        .inset(by: 20)
        .fill(Color.red)
        .frame(width: 100, height: 100)
}

inset only applies to insetable shapes, not views like Text. For that reason, it should be a property on the shape.

Question: Are we going to follow in some way Swift protocols like Shape and InsetableShape? Would we use mixins?

scale(x:y:anchor:)

This modifier scales a shape by the specified scale factors along the x-axis and y-axis. The anchor parameter determines the point around which the scaling occurs.

Screenshot 2024-06-05 at 11 31 42
HStack {
    Rectangle()
        .scale(x: 1.5, y: 0.5, anchor: .center)
        .fill(Color.blue)
        .frame(width: 100, height: 100)

    Circle()
        .scale(x: 0.8, y: 0.8, anchor: .topLeading)
        .fill(Color.red)
        .frame(width: 100, height: 100)
}

Only shapes have a scale method. If you want to scale other views, you need to use scaleEffect. So, scale should be a property of a shape.

rotation(_:anchor:) and rotationEffect(_:anchor:)

rotation rotates a shape around and anchor while rotationEffect rotates any view around an anchor. I'm not sure why shape needs its own method.

Screenshot 2024-06-05 at 13 31 52
HStack {
    Rectangle()
        .rotation(Angle(degrees: 32))
        .fill(Color.blue)
        .frame(width: 100, height: 100)

    Text("Hello")
        .rotationEffect(Angle(degrees: 73))
}

rotation could be a shape widget property while rotationEffect could be a composable widget.

Generalizations

Anything that belongs to Shape should be a property, not a widget itself.

Property methods that are defines as extensions to View could be their own composable widgets:

Here are a few methods that belong to View and thus also apply to shapes:

Those could probably all be widgets.

Both Shape and View?

There is an offset(x:y:) in both Shape and View. I'm not sure why that is. I suppose it could be a widget and a property, though this seems like it is duplicating the logic. More study needed on this one.

matthew-carroll commented 4 months ago

Thanks for looking into it. That rationale makes sense to me. Feel free to start with whatever piece you'd like.