GithubHelp home page GithubHelp logo

square / blueprint Goto Github PK

View Code? Open in Web Editor NEW
665.0 20.0 45.0 13.56 MB

Declarative UI construction for iOS, written in Swift

Home Page: https://square.github.io/Blueprint/

License: Apache License 2.0

Ruby 0.26% Swift 99.63% Shell 0.11%
swift ui declarative declarative-ui square uikit

blueprint's Introduction

Build Status

Blueprint

Declarative UI construction for iOS, written in Swift

Blueprint greatly simplifies the task of building and updating views as application state changes.

We still consider Blueprint experimental (and subject to major breaking API changes), but it has been used within Square's production iOS apps.

let rootElement = Label(text: "Hello from Blueprint!")
let view = BlueprintView(element: rootElement)

Generated documentation is available at square.github.io/Blueprint

Getting Started

Swift Package Manager

SwiftPM compatible

If you are developing your own package, be sure that Blueprint is included in dependencies in Package.swift:

dependencies: [
    .package(url: "[email protected]:square/Blueprint.git", from: "0.3.0")
]

In Xcode 11+, add Blueprint directly as a dependency to your project with File > Swift Packages > Add Package Dependency.... Provide the git URL when prompted: [email protected]:square/Blueprint.git.

Cocoapods

CocoaPods compatible

If you use CocoaPods to manage your dependencies, simply add BlueprintUI and BlueprintUICommonControls to your Podfile:

pod 'BlueprintUI'
pod 'BlueprintUICommonControls'

What does this library do?

Blueprint provides an architecture that allows you to:

  • Declaratively define a UI hierarchy as pure values (Swift structs and enums).
  • Display that hierarchy within your application.
  • Update that hierarchy as application state changes (including animated transitions).
When should I use it?

Use Blueprint any time you want to display a view hierarchy, but don't want to manage view lifecycle (hint: managing view lifecycle is a large portion of most conventional UIKit code). There are times when you want to manage view lifecycle (complex animations and transitions are a good example), and for these cases you may want to stick with a conventional approach.

How does it interact with UIKit?

Blueprint is not a replacement for UIKit! From the beginning, Blueprint has been designed as a compliment to all of the powerful tools that come with the platform. You can use Blueprint to manage the display of a single view controller, or of a single view representing a small part of the screen. Likewise, it's straightforward to host standard views and controls within a blueprint hierarchy, always leaving you with an escape hatch.


Documentation

Getting Started

  1. Hello, World

  2. The Element Hierarchy

  3. Building Custom Elements

  4. Layout

Reference

  1. Element

  2. BlueprintView

  3. ViewDescription

  4. Transitions

Tutorials

Tutorial setup instructions

  1. Using Blueprint in a View Controller

  2. Building a receipt layout with Blueprint


Adding Blueprint to an existing project

Two modules are provided:

  • BlueprintUI contains the core architecture and layout types.
  • BlueprintUICommonControls includes elements representing some common UIKit views and controls.

Blueprint is available via CocoaPods. Add it to your Podfile to integrate:

target MyTarget do
    pod 'BlueprintUI'
    pod 'BlueprintUICommonControls'
end

Release instructions


Copyright 2019 Square, Inc.

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

blueprint's People

Contributors

balestrapatrick avatar bencochran avatar cbowns avatar chrissonoda avatar connorcimowsky avatar dependabot[bot] avatar dostrander avatar gabreho avatar jacksoncheek avatar jamieq avatar kcsiegal avatar kylebshr avatar kyleve avatar ldstreet avatar lechristian avatar lickel avatar mattfaluotico avatar meherkasam avatar meherkasam-square avatar n8chur avatar narenh avatar nononoah avatar nshaosong avatar nsillik avatar robmaceachern avatar royalpineapple avatar timdonnelly avatar vocaro avatar watt avatar zradke avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blueprint's Issues

Snap views to screen pixel boundaries after layout

To avoid fuzziness caused by views that are not on pixel boundaries (generally due to rounding), we should snaps edges to pixel bounds. Ideally, a generalized solution that does not require individual elements to do their own explicit rounding. AutoLayout and SwiftUI have such features.

Overlay is not well documented

It should specify that the z-ordering of children is back-most to front-most. (Or if that is not guaranteed, that should be documented instead.)

AttributedLabel measuring is wrong sometimes

Problems

There are a couple of related problems with the AttributedLabel element (and by extension, Label).

  1. Measurement is wrong when numberOfLines is set to a value other than 0.

    The current measurement behavior uses NSAttributedString.boundingRect(with:options:context:), which has no affordance for line limits.

  2. Rounding assumes the main screen's scale by default, and will be wrong if rendered on a different screen.

    This one is definitely an edge case, but we should at least consider solving this if it's feasible.

Potential solutions

Static UILabel for measuring

We solved problem 1 internally by using a static prototype instance of UILabel. During measuring we apply the view description to the label and then call sizeThatFits. This solution operates on the assumption that measuring is always done on the main thread, so two labels cannot be measured concurrently.

This does not solve problem 2: if UILabel is not in a window, it returns results rounded to the main screen's scale.

TextKit & UITextView

TextKit measuring methods allow us to do line limits, but in experimentation I have found that TextKit measuring doesn't match UILabel. In fact, it's quite difficult (maybe impossible, without knowing its internals) to accurately reproduce its measuring behavior across every combination of line break mode and line limit.

It's important to get those values right โ€” if they're off slightly, it can cause text to be truncated in places where it should have fit. If we want to use TextKit we may have to switch to UITextView or do our own string rendering to ensure it matches our measuring.

We could also potentially switch between using a UILabel for single-line text and a UITextView for multi-line text, or offer these as separate elements.

Since TextKit does not automatically do any rounding, we'd be free to do this ourselves (perhaps by passing the screen scale down through the Environment).

Custom rendering (hard mode)

As mentioned above, we could side-step UIKit entirely and use TextKit/CoreText to measure and then render strings.

This seems like overkill but it's an option.

[Question] Questions about Blueprint

Hi there!

A colleague linked me this repo and I've been browsing the source code. Just some questions:

  • This doesn't use AutoLayout right? Doesn't seem like it from an initial glance.
  • Are you reusing views in a scenario like UITableView/UICollectionView? LayoutKit by LinkedIn does this.

Looking forward to see how this library evolves ๐Ÿ‘ Awesome job

Stack layout should measure cross axis after layout axis has been allotted

Stack layout currently measures its children only once, using the stack's entire bounds as a constraint. These basis sizes are then used for the cross axis sizes, while the layout axis sizes are adjusted according to the child priorities.

For elements like a Label with text wrapping, where the height depends on the width, this can result in undesirable cross axis measurements.

Here's an example:

struct TestElement: ProxyElement {
    var elementRepresentation: Element {
        return Column { column in
            column.verticalUnderflow = .justifyToCenter
            column.horizontalAlignment = .fill

            column.add(child: row(withMargin: 0))
            column.add(child: row(withMargin: 50))
        }
    }

    func row(withMargin margin: CGFloat) -> Element {
        return Row { row in
            row.horizontalOverflow = .condenseProportionally

            row.add(
                growPriority: 0,
                shrinkPriority: 0,
                child: Spacer(size: CGSize(width: margin, height: 0)))

            row.add(
                growPriority: 1,
                shrinkPriority: 1,
                child: Label(text: "This is a long label for testing. It takes up 2 lines if there are no margins, but needs 3 if we add some margins."))

            row.add(
                growPriority: 0,
                shrinkPriority: 0,
                child: Spacer(size: CGSize(width: margin, height: 0)))
        }
    }
}

Screen Shot 2020-02-25 at 12 03 54 PM

In the second instance, the label should ideally be wrapped onto 3 lines. However, its height measurement is the same as the first instance, and does not take into account the adjusted width. It ends up truncated.

To fix this, I think we should re-measure children after determining the layout axis, and use those measurements for the cross axis sizes, rather than the basis sizes.

Proposal: SwiftUI-like EnvironmentValues

I've prototyped a potential implementation of an environmental context like SwiftUI's @Environment functionality. I'd like to get some feedback on this approach before opening a PR.

Like SwiftUI, it features an extensible, strongly-typed dictionary, where keys are types, and each key has a default value. Environment modifications in outer elements cascade to the contained elements at layout time without the two having any knowledge of each other.

Unlike SwiftUI, it does not use property wrappers, and is less magical. Instead there is a new protocol ContextElement that works just like ProxyElement, but instead of an elementRepresentation property it has a function:

public protocol ContextElement: Element {
    func elementRepresentation(in environment: Environment) -> Element
}

To accomplish this I added a new ContentStorage implementation for ElementContent that holds a closure, and defers building its child elements until layout time. By deferring until layout time, we can use the layout pass to cascade an Environment instance through the entire tree.

This strategy requires building children twice: once for measuring and once for layout. Leaf nodes might be built numerous times if there are elements upstream in the tree that measure their children for layout.

Prototype

Because the environment is required to build children, and building children is required for measuring, this change affects all Measurables. I tried two implementations.

https://github.com/square/Blueprint/tree/watt/environment-api-measurable

This branch changes Measurable and adds an Environment argument.

https://github.com/square/Blueprint/tree/watt/environment-api-nonmeasurable

This branch does not change Measurable. Some types simply no longer conform. ElementContent has a function like this instead:

func measurable(in environment: Environment) -> Measurable 

The second approach had a smaller impact, but I could see doing it either way.

Example

This commit contains an example use in the sample app.

Events can be dropped between updates

Currently, the update process for elements to view in BlueprintView is asynchronous.

Specifically, when element is set, there is a call to setNeedsViewHierachyUpdate which sets a flag that an update must happen, then a call to setNeedsLayout where the update will happen on the next layout pass.

This has the side effect of potentially losing events if the previous closure bound to an element/view (like a text field, for instance) changes or is invalidated after being received.

UIKit will queue the events, so only send one per runloop pass, however there is a gap between the first being handled and the closure being updated (since it does not updated until the next layout pass has completed). Using Blueprint with Workflows can easily produce this with very fast input to text fields (eg: with a KIF test, but can be reproduced with a keyboard). Since the sink (event handler) in workflows is only valid for a single event in a single render pass, the behavior seen is a crash (or would be dropped events if it was not asserting) because of the gap in updates.

The naive "fix" for this would be to change BlueprintView's didSet on element to update the hierarchy, ie:

    /// The root element that is displayed within the view.
    public var element: Element? {
        didSet {
            setNeedsViewHierarchyUpdate()
+            // Immediately update the hierarchy when element is set, instead of waiting for the layout pass
+            updateViewHierarchyIfNeeded()
        }
    }

This is the naive fix, as blueprint should not support reentrant updates, so will likely need a bit of exploration to determine a "safe" way to make this update be synchronous.

And example view controller that reproduces what the behavior would be when used with Workflows: (a sink that invalidates after every update):

import UIKit
import BlueprintUI
import BlueprintUICommonControls


public final class SinkBackedBlueprintViewController: UIViewController {

    private class Sink<Value> {
        var valid = true
        var onEvent: (Value) -> Void

        init(onEvent: @escaping (Value) -> Void) {
            self.onEvent = onEvent
        }

        func send(event: Value) {
            if !valid {
                fatalError("Old sink")
            }
            self.onEvent(event)
            invalidate()
        }

        func invalidate() {
            valid = false
        }
    }

    private let blueprintView: BlueprintView
    private var text: String = ""
    private var sink: Sink<String>

    public init() {
        self.blueprintView = BlueprintView(frame: .zero)
        self.sink = Sink(onEvent: { _ in })

        super.init(nibName: nil, bundle: nil)
    }

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

    public override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(blueprintView)
        update(text: "")
    }

    public override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        blueprintView.frame = view.bounds
    }

    func update(text: String) {
        self.text = text
        generate()
    }

    func generate() {
        var textField = TextField(text: text)
        let sink = Sink<String>(onEvent: { [weak self] updated in
            self?.update(text: updated)
        })
        textField.onChange = { [sink] updated in
            sink.send(event: updated)
        }
        let label = AccessibilityElement(label: "email", value: nil, hint: nil, traits: [], wrapping: textField)

        blueprintView.element = Column { col in
            col.horizontalAlignment = .fill
            col.minimumVerticalSpacing = 8.0
            col.add(child: Box(backgroundColor: .green, cornerStyle: Box.CornerStyle.square, wrapping: nil))
            col.add(
                child: Box(
                    backgroundColor: .red,
                    cornerStyle: Box.CornerStyle.square,
                    wrapping: label))
            col.add(child: Box(backgroundColor: .green, cornerStyle: Box.CornerStyle.square, wrapping: nil))
        }
    }
}

Add README FAQ about SwiftUI

Blueprint and SwiftUI both tackle a similar kind of problem, so for developers outside Square, it would be helpful to understand how they differ โ€“ for example, Blueprint's support for deploying to older versions of iOS.

Coordinate Space Anchors

Create (or upstream) a coordinate space reading element and an "anchor" type that holds the last emitted coordinate space. These can be used together to imperatively read an element's position on screen after layout.

column.verticalUnderflow = .spaceEvenly in scrollable not working

let column = Column { column in
column.verticalUnderflow = .spaceEvenly
column.add(child: Element1())
column.add(child: Element2(){
}.box(clipsContent: true).constrainedTo(width: .absolute(343), height: .absolute(48))
)}.scrollable(.fittingHeight){
$0.alwaysBounceVertical = true
$0.contentInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
}

Here no flexible space added between two childs: Element1() and Element2() but column.verticalUnderflow = .spaceEvenly

View Recycling

Because the consumer is interacting with elements, rather than the views backing them, view recycling could be done for the backing views with no changes in the public API. Each time an element needed a view, it could request it from a factory that doles out recycled views rather than creating its own.

The big questions are how to reclaim views and how to reset them for later use. The factory could maintain its own strong reference to all recyclable views, and then in the deinit method of an element, the element could notify the recycler that the view is now available to be reclaimed. The resetting part is less trivial but still doable.

Secondary elements

SwiftUI has a concept of secondary views, which are attached to a parent view but do not play a part in layout. Handy for e.g. putting a badge on an icon without changing the layout of the icon.

This can almost be replicated in Blueprint with Overlays, but really needs custom layout in order to let the secondary element take any size or position without affecting the parent element. Blueprint should provide a dedicated element for this pattern.

GeometryReader

It would be handy to have an element similar to SwiftUI's GeometryReader.

This would allow for layout-time sizing behaviors to be written ad hoc without having to build a Layout implementation.

Appeared/disappeared callbacks

ViewDescription should provide a way to run a closure when the view appears or disappears. Could possibly be a property on the VisibilityTransition.

SwiftUI provides onAppear (which runs before appearance animations) and onDisappear (which runs after). We may want to provide separate hooks for the start & end of animations.

Wrapped elements should be the last parameter

Many Blueprint elements that wrap a single child have an initializer defined like this:

WrapperElement(wrapping: Element, param1: X, param2: Y)

Where the wrapped element is the first parameter. Unfortunately, this ordering does not lend itself to inline-nested Element compositions, because it pushes the domain-related parameters away from the type they're associated with.

Consider the following element construction:

var constrainedInsetRow: Element {
    return ConstrainedSize(
        wrapping: Inset(
            wrapping: Row { row in
                row.verticalAlignment = .center

                for leadingElement in self.leadingElements {
                    row.add(growPriority: 0, shrinkPriority: 0, child: leadingElement)
                }

                if let contentElement = self.contentElement {
                    row.add(child: contentElement)
                }

                for trailingElement in self.trailingElements {
                    row.add(growPriority: 0, shrinkPriority: 0, child: trailingElement)
                }
            },
            uniformInset: 8
        ),
        height: .atLeast(44)
    )
}

The uniformInset parameter is pushed 16 lines away from the Inset it is associated with, and height is 19 lines away from ConstrainedSize.

If we move the wrapping parameters to the end of the argument list, it looks like this:

var constrainedInsetRow: Element {
    return ConstrainedSize(
        height: .atLeast(44),
        wrapping: Inset(
            uniformInset: 8,
            wrapping: Row { row in
                row.verticalAlignment = .center

                for leadingElement in self.leadingElements {
                    row.add(growPriority: 0, shrinkPriority: 0, child: leadingElement)
                }

                if let contentElement = self.contentElement {
                    row.add(child: contentElement)
                }

                for trailingElement in self.trailingElements {
                    row.add(growPriority: 0, shrinkPriority: 0, child: trailingElement)
                }
            }
        )
    )
}

Now it is immediately clear that height goes with ConstrainedSize and uniformInset goes with Inset, without really affecting the clarity of the nesting either.

Can we change the order of the arguments on ConstrainedSize, Inset, etc. to consistently have the wrapped element be last?

Create a contribution guide

This repo needs a contribution guide containing info like:

  • a reminder to update the changelog
  • updating the docs with new elements

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.