Статьи

Компонент пользовательского интерфейса на основе Swift Node для iOS

С захватывающими новостями о новом iPad Pro  от Apple  и  новых фильтрах Core Image в iOS 9  (некоторые из них работают на  Metal Performance Shaders !), Сейчас самое подходящее время для меня, чтобы начать работать над версией 3 моего приложения для обработки изображений на основе узлов,  Nodality.

Я начал писать версию Nodality для Swift, когда я был немного мокрым за ушами, и, честно говоря, чтобы все работало хорошо под Swift 2, он мог выиграть от полного переписывания. Часть кода, основанная на пользовательском интерфейсе узла, очень тесно связана с логикой фильтрации изображений, и ее было бы невозможно использовать повторно для приложений другого типа. 

Итак, помня об этом, я перестроил этот код, чтобы полностью отделить логику отображения от обработки изображений, и открыл его для приема инъекционных средств визуализации. Это означает, что мой компонент пользовательского интерфейса,  ShinpuruNodeUI , может быть легко использован в другом приложении. Например, он может отображать схемы базы данных, рабочие процессы бизнес-процессов или даже аудиоустройства и фильтры.

В этом посте рассказывается о том, как вы могли бы реализовать ShinpuruNodeUI в своем собственном приложении, а не о внутреннем компоненте самого компонента. Если у вас есть какие-либо вопросы о самом компоненте, пожалуйста, не стесняйтесь комментировать этот пост или связаться со мной через Twitter, где я  @FlexMonkey .

Проект  поставляется в комплекте с калькулятором приложения простой демонстрации , который был моим по умолчанию «Getting Started» приложение для узлов на основе пользовательских интерфейсов , так как я  первый начал программировать их еще в 2008 году ! Код калькулятора показывает, как вы можете реализовать свою собственную бизнес-логику в приложении на основе узлов.

Интерактивный дизайн

Основные жесты пользователя:

  • Чтобы создать новый узел, долгое нажатие на фон
  • Чтобы переключить отношения между двумя узлами
    • Длительно нажмите исходный узел, пока цвет фона не станет светло-серым
    • Компонент теперь находится в «режиме создания отношений»
    • Коснитесь ввода (светло-серая строка) на цели, и связь будет создана (или удалена, если такая связь существует)
  • Чтобы удалить узел, нажмите маленький значок корзины на панели инструментов узла.
  • Весь холст можно масштабировать и панорамировать 

Я играл с более традиционным жестом перетаскивания для создания отношений, но обнаружил, что этот шаблон «режима создания отношений» хорошо работает для сенсорных устройств. В этом выпуске нет ничего, что мешало бы пользователям создавать круговые отношения, которые приводили к сбою приложения, — есть исправление для этого ожидания. 

Монтаж

ShinpuruNodeUI устанавливается вручную и требует, чтобы вы скопировали следующие файлы в ваш проект:

  • SNNodeWidget A node widget display component
  • SNNodesContainer The superview to the node widgets and background grid
  • SNRelationshipCurvesLayer The CAShapeLayer that renders the relationship curves
  • SNView The main ShinpuruNodeUI component 
  • ShinpuruNodeDataTypes Contains supporting classes and protocols: 
    • SNNode A node data type
    • SNDelegate The delegate protocol 
    • SNItemRenderer, SNOutputRowRenderer, SNInputRowRenderer base classes for renderers that you can extend for your own implementation 

This is bound to change as the component evolves and I’ll keep the read-me file updated.

Instantiating the Component

Set up is pretty simple. Instantiate an instance:

    let shinpuruNodeUI = SNView()

Add it as a subview:

    view.addSubview(shinpuruNodeUI)

…and set its bounds:

 shinpuruNodeUI.frame = CGRect(x: 0,
        y: topLayoutGuide.length,
        width: view.frame.width,

        height: view.frame.height - topLayoutGuide.length)

An Overview of SNDelegate

For ShinpuruNodeUI to do anything interesting, it needs a nodeDelegate which is of type SNDelegate. The nodeDelegateis responsible for everything from acting as a datasource to providing renderers and includes these methods:

  • dataProviderForView(view: SNView) -> [SNNode]? returns an array of SNNode instances
  • itemRendererForView(view: SNView, node: SNNode) -> SNItemRenderer returns the main item renderer for the view. In my demo app, these are the red and blue squares that display the value of the node.
  • inputRowRendererForView(view: SNView, node: SNNode, index: Int) -> SNInputRowRenderer returns the renderer for the input rows. In my demo app, these are the light grey rows that display the value of an input node.
  • outputRowRendererForView(view: SNView, node: SNNode) -> SNOutputRowRenderer returns the renderer for the output row. In my demo app, this is the dark grey row at the bottom of the node widget.
  • nodeSelectedInView(view: SNView, node: SNNode?) this method is invoked when the user selects a node. In my demo app, it’s when I update the controls in the bottom toolbar.
  • nodeMovedInView(view: SNView, node: SNNode) this method is invoked when a node is moved. This may be an opportunity to save state.
  • nodeCreatedInView(view: SNView, position: CGPoint) this method is invoked when the user long-presses on the background to create a new node. ShinpuruNodeUI isn’t responsible for updating the nodes data provider, so this is an opportunity to add a new node to your array.
  • nodeDeletedInView(view: SNView, node: SNNode) this method is invoked when the user clicks the trash-can icon. Again, because ShinpuruNodeUI is purely responsible for presentation, this is the time to remove that node from your model and recalculate other node values as required. 
  • relationshipToggledInView(view: SNView, sourceNode: SNNode, targetNode: SNNode, targetNodeInputIndex:Int) this method is invoked when the user toggles a relationship between two views and, much like deleting a node, you’ll need to recalculate the values of affected nodes.
  • defaultNodeSize(view: SNView) -> CGSize returns the size of a newly created node widget to ensure new node widgets are nicely positioned under the user’s finger (or maybe Apple Pencil)

In my demo app, its the view controller that acts as the nodeDelegate.

So, although ShinpuruNodeUI renders your nodes and their relationships and reports back user gestures, there’s still a fair amount that the host application is responsible for. Luckily, my demo app includes basic implementations of everything required to get you up and running.

Renderers

Node widgets requires three different types of renderers, defined by the SNDelegate above. My demo project includes basic implementations of all three in the DemoRenderers file. 

The three renderers should sub class SNItemRenderer, SNOutputRowRenderer, SNInputRowRenderer and, at the very least,  implement a reload() method and override intrinsicContentSize(). reload() is invoked when ShinpuruNodeUI needs the renderer to update itself (in the case of the demo app, this is generally just updating the text property of a label.

Implementing a Calculator Application 

My demo calculator app contains a single struct, DemoModel, which manages the logic for updating its nodes when values or inter-node relationships are changed. The view controller mediates between an instance of DemoModel and an instance of ShinpuruNodeUI. 

When a numeric (red) node is selected and its value changed by the slider in the bottom toolbar, the view controller’s sliderChangeHandler() is invoked. This method double checks that ShinpuruNodeUI has a selected node, updates that node’s value and then calls theDemoModel‘s updateDescendantNodes() method. updateDescendantNodes() returns an array of all the nodes affected by this update which are then passed back to ShinpuruNodeUI to update the user interface:

    func sliderChangeHandler()
    {
        if let selectedNode = shinpuruNodeUI.selectedNode?.demoNode where selectedNode.type == .Numeric
        {
            selectedNode.value = DemoNodeValue.Number(round(slider.value))

            demoModel.updateDescendantNodes(selectedNode).forEach{ shinpuruNodeUI.reloadNode($0) }
        }

    }

Similarly, when a blue operator node (i.e. add, subtract, multiply or divide) is selected and the operator is changed, the selected node’s type is changed and updateDescendantNodes() is executed followed by reloading the affected nodes:


    func operatorsControlChangeHandler()
    {
        if let selectedNode = shinpuruNodeUI.selectedNode?.demoNode where selectedNode.type.isOperator
        {
            selectedNode.type = DemoNodeType.operators[operatorsControl.selectedSegmentIndex]

            demoModel.updateDescendantNodes(selectedNode).forEach{ shinpuruNodeUI.reloadNode($0) }
        }

    }

ShinpuruNodeUI can invoke delegate methods that will require DemoModel to update its nodes — specifically, deleting a node or changing a relationship. To that end, both DemoModel‘s deleteNode() and relationshipToggledInView() return an array of affected nodes which are passed back to ShinpuruNodeUI:

  func nodeDeletedInView(view: SNView, node: SNNode)
    {
        if let node = node as? DemoNode
        {
            demoModel.deleteNode(node).forEach{ view.reloadNode($0) }

            view.renderRelationships()
        }
    }

    func relationshipToggledInView(view: SNView, sourceNode: SNNode, targetNode: SNNode, targetNodeInputIndex: Int)
    {
        if let targetNode = targetNode as? DemoNode,
            sourceNode = sourceNode as? DemoNode
        {
            demoModel.toggleRelationship(sourceNode, targetNode: targetNode, targetIndex: targetNodeInputIndex).forEach{ view.reloadNode($0) }
        }

    }

Conclusion

ShinpuruNodeUI is still evolving, but is in a state where it can provide the basis for an iOS node based application for a wide variety of use cases. It offers a clear separation of concerns between the presentation of the node relationships, the presentation of the actual data and the computation. 

The complete project is available at my GitHub repository here. Keep an eye out on my blog or on Twitter for an updates!