Earlier, we looked at how to create a basic WASM-based front-end application in Rust using the Yew networking framework. In this tutorial, we’ll show you how to do something similar with the Iced. Rs GUI library.

To demonstrate the operations of Iced. Rs, we will use Iced and Rust to build a very basic front-end application that uses JSONPlaceholder to fetch data. We’ll take the posts and display them in a list, each with a detailed link that leads the user to the full post with comments.

Iced.rs vs. Yew

The biggest difference between YEw-Yew and YEw-YEW is that Yew-Yew is purely to build web applications, while Yew-Yew is really focused on cross-platform applications; The Web is just one of several platforms where you can build applications.

In terms of style, Yew will feel familiar to those who know React and JSX, while Iced. Rs is inspired by the dreamlike Elm in terms of architecture.

It should also be noted that Iced. Rs is very much in the early and active development stages. While it is perfectly possible to use it to build basic applications, the ecosystem is not particularly mature. Documentation and examples aside, it’s a bit bumpy to get off to a good start at this early stage, especially if you want to build something complex.

Nevertheless, the project appears to be well managed and is moving rapidly along its roadmap.

Set the Iced. Rs

To follow this tutorial, all you need is an up-to-date installation of Rust (Rust 1.55 is the latest version at the time of this writing).

First, create a new Rust project.

cargo new rust-frontend-example-iced
cd rust-frontend-example-iced

Copy the code

Next, edit the Cargo. Toml file and add the dependencies you need.

[dependencies] iced_web = "0 "iced = {version = "0 ", features = "0 ", Features = ["derive"]} serde_json = "1.0" wasM-bindgen = "0.2.69" reqwest = {version = "0.11", features = ["json"]}Copy the code

In this tutorial, we will use Iced. Rs as our front-end framework. Since Iced. Rs is a cross-platform GUI library, we also need to add ICED_Web, which enables us to create a wASM-based single-page Web application from the Iced. Rs application.

To get the data in JSON format, we’ll also add Reqwest and Serde. We’ll pin the WASM-Bindgen version so we don’t run into any compatibility issues when we build. This is useful because the Wasm ecosystem is still constantly changing, and using a specific version for your project ensures that you won’t wake up one day and find a bad project.

fromindex.html

We use Trunk to abstract away the minutiae of building a Wasm application. Trunk wants an index.html file at the root of the project, which we will provide.

<! DOCTYPE html> <html> <head> <meta http-equiv="Content-type" content="text/html; charset=utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Tour - Iced</title> </head> <body> <script type="module"> import init from "./iced/iced.js"; init('./iced/iced_bg.wasm'); </script> </body> </html>Copy the code

Here, we simply create an HTML skeleton and add the snippets that include our compiled Iced. Rs source code.

We didn’t add any CSS here; In Iced. Rs, we built our own custom widgets and styled them in our code. Of course, CSS can also be added, but in many cases these styles are overridden by inline styles added to the output HTML by Iced. Rs.

With all of this set up, we can start writing some Rust code.

The data access

We’ll start by setting up our data access layer. To do this, we’ll create a data.rs file next to main.rs in the SRC folder, and we’ll use mod data; Add this data module to our main.rs.

Since our plan is to get a list of posts and then get the details of a Post and its comments, we need structure for Post and Comment.

use serde::Deserialize;

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Post {
    pub id: i32,
    pub user_id: i32,
    pub title: String,
    pub body: String,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Comment {
    pub post_id: i32,
    pub id: i32,
    pub name: String,
    pub email: String,
    pub body: String,
}

Copy the code

Next, we’ll implement some data fetching routines to get the actual JSON data from JSONPlaceholder.

impl Post { pub async fn fetch_all() -> Result<Vec<Post>, String> { let url = String::from("https://jsonplaceholder.typicode.com/posts/"); reqwest::get(&url) .await .map_err(|_| String::new())? .json() .await .map_err(|_| String::new()) } pub async fn fetch(id: i32) -> Result<Post, String> { let url = format! ("https://jsonplaceholder.typicode.com/posts/{}", id); reqwest::get(&url) .await .map_err(|_| String::new())? .json() .await .map_err(|_| String::new()) } }Copy the code

In this simple example, we won’t handle connection errors, so we’ll just return an empty string here. However, you can easily extend it by returning error messages or creating a custom error enumeration to handle different error cases in your application.

We have a function to get all posts. To perform HTTP requests, we use the ReqwestHTTP client, which also supports Wasm as a build target.

To get the details of the Post, we create a second function that takes the ID of the Post as an argument, passes it to JSONPlaceholder, and returns a result of type Post.

Now we need to do the same for reviews.

impl Comment { pub async fn fetch_for_post(id: i32) -> Result<Vec<Comment>, String> { let url = format! ( "https://jsonplaceholder.typicode.com/posts/{}/comments/", id ); reqwest::get(&url) .await .map_err(|_| String::new())? .json() .await .map_err(|_| String::new()) } }Copy the code

This data access function also takes a post ID and gets all the comments for a given post, deserializing JSON into a vector of Comment structures.

With our data module, we can now get posts and comments and have a nice structure that contains the data.

Build the user interface with Iced. Rs

Now it’s time to start building the user interface.

An Iced. Rs application, similar to ELM, consists of four central concepts.

  • state
  • The message
  • update
  • To view

State is the State of your application. For example, in our case, this is the data we fetched and displayed from JSONPlaceholder.

Messages, used to trigger processes within the application. They can be user interactions, timed events, or any other event that might change something in the application.

Update logic is used for these Messages. For example, in our application, we might have a Message to navigate to a detail page. In our Update logic to the message, we will set the route and get the data, so we can Update the state of the application from the List to the Detail.

Finally, the View logic describes how to render a portion of the application. It displays State and may produce Messages when the user interacts.

We’ll start by building very simple widgets for posts and comments, which will include rendering logic for those widgets, and then use them to connect all the basic routing to our data access.

Post and comment widgets

We’ll start with the Comment widget because it’s very minimalist and simple.

struct Comment { comment: data::Comment, } impl Comment { fn view(&self) -> Element<Message> { Column::new() .push(Text::new(format! ("name: {}", self.comment.name)).size(12)) .push(Text::new(format! ("email: {}", self.comment.email)).size(12)) .push(Text::new(self.comment.body.to_owned()).size(12)) .into() } }Copy the code

Basically, our Comment widget just has a data::Comment as its state, so once we get the Comment data from our data layer, we can start creating these widgets.

The view, in this case, describes how to render a Comment. In this case, we create a Column, which in HTML would just be a div. However, there is also a Row and other pre-existing Container widgets that we can use to structure our user interface in a responsive and coherent manner.

In this column, we simply add some ICED ::Text widgets, which basically compile to P (Text paragraphs). We give it a string, and we set the size manually.

At the end, we use.into(), because the view returns an Element

, where Element is just a common part of Iced, and Message is our Message abstraction, which we’ll look at later.

Now let’s look at the implementation of the Post Widget.

struct Post { detail_button: button::State, post: data::Post, } impl Post { fn view(&mut self) -> Element<Message> { Column::new() .push(Text::new(format! ("id: {}", self.post.id)).size(12)) .push(Text::new(format! ("user_id: {}", self.post.user_id)).size(12)) .push(Text::new(format! ("title: {} ", self.post.title)).size(12)) .push(Text::new(self.post.body.to_owned()).size(12)) .into() } fn view_in_list(&mut self) ->  Element<Message> { let r = Row::new().padding(5).spacing(5); r.push( Column::new().spacing(5).push( Text::new(self.post.title.to_owned()) .size(12) .vertical_alignment(VerticalAlignment::Center), ), ) .push( Button::new(&mut self.detail_button, Text::new("Detail").size(12)) .on_press(Message::GoToDetail(self.post.id)), ) .into() } }Copy the code

The fundamental difference is that in our example, a Post also contains a Detail_button. This enables us to create detail buttons for our list of posts.

We also have two different post rendering functions: Verbose view, which is similar to the view function in Comment, and a view_in_list function that uses some padding and spacing (padding and margin in web speak) to create a list element that aligns everything, and importantly, Add the Detail view button at the end of the row.

If you look at the documentation for some of the styling options, you’ll recognize many of the options available on the web, so styling your components is very simple.

To create a button, we need a Button ::State. We can add an action when we click on it, such as Message::GoToDetail(self.post.id) if the button is pressed.

With these two basic widgets, let’s build our App widget and start building a working application.

Put it all together

In our main.rs, we can start with imports and our main function.

use iced::{
    button, executor, Align, Application, Button, Clipboard, Column, Command, Element, Row,
    Settings, Text, VerticalAlignment,
};

mod data;

pub fn main() -> iced::Result {
    App::run(Settings::default())
}

Copy the code

Running an Iced. Rs application is straightforward. We need something that implements the qualities of Application — App in our case — and then we can call run() on it with the default Settings.

With these Settings, we can set some basic application Settings, such as default fonts, window Settings (for native applications), and similar things.

Next, let’s take a look at our App structure and the application state within it.

#[derive(Clone, Debug)]
enum Route {
    List,
    Detail(i32),
}

struct App {
    list_button: button::State,
    route: Route,
    posts: Option<Vec<Post>>,
    post: Option<Post>,
    comments: Option<Vec<Comment>>,
}

Copy the code

In our App, we have our list_button button state, which goes back to the home button, our root.

We then align the state of the route with that of our two routes List and Detail. I couldn’t find any mature routing libraries for ICed_Web, so we’re going to build our own very simple route that doesn’t require changing urls in the browser, history, or return processing.

If you are interested in building a more robust route, you can add Web-sys to your dependencies.

[dependencies. Web-sys] version = "0.3.32" features = ["Document", "Window",]Copy the code

Then you can set the URL, such as using.

let win = web_sys::window().unwrap_throw(); win.location() .set_hash(&format! ("/detail/{}", id)) .unwrap_throw();Copy the code

But in this tutorial, we won’t go down that rabbit hole.

In addition, we reserve state for posts, posts, and comments. These are just the options associated with our widget, Post and Comment, and the vectors to populate them with.

Next, let’s define our Message structure, which defines the data flow in our application.

#[derive(Debug, Clone)]
enum Message {
    PostsFound(Result<Vec<data::Post>, String>),
    PostFound(Result<data::Post, String>),
    CommentsFound(Result<Vec<data::Comment>, String>),
    GoToList,
    GoToDetail(i32),
}

Copy the code

In our application, there are five pieces of information.

The basic ones are GoToList and GoToDetail, which are basically our routing information. These messages are triggered if someone clicks on the Home or Detail link.

PostsFound, PostFound, and CommentsFound are then triggered when the data comes back from our data access layer. We’ll look at the processing of these messages later.

Let’s start implementing Application characteristics for our App.

impl Application for App {
    type Executor = executor::Default;
    type Flags = ();
    type Message = Message;

    fn new(_flags: ()) -> (App, Command<Message>) {
        (
            App {
                list_button: button::State::new(),
                route: Route::List,
                posts: None,
                post: None,
                comments: None,
            },
            Command::perform(data::Post::fetch_all(), Message::PostsFound),
        )
    }

    fn title(&self) -> String {
        String::from("App - Iced")
    }

Copy the code

An Executor is actually an asynchronous Executor that can run futures, such as Async-IO or Tokio. We’re just going to use the default. We also don’t use any flags and set up our Message structure for messages.

In the new function, we just set the default values for all the properties, return App, and importantly, return a new Command. This mechanism for returning Command

is the way messages are fired in Iced. Rs.

In this case, in creating the App, we execute Post::fetch_all Future from our data access layer and provide a Message that will be called with the result of the future — in this case, Message::PostsFound.

This means that when the application is opened, we immediately grab all the posts so that we can display them.

With title(), we can also set the title, but that’s not particularly interesting.

Let’s look at how we manage Messages in update.

fn update(&mut self, message: Message, _c: &mut Clipboard) -> Command<Message> { match message { Message::GoToList => { self.post = None; self.comments = None; self.route = Route::List; Command::perform(data::Post::fetch_all(), Message::PostsFound) } Message::GoToDetail(id) => { self.route = Route::Detail(id); self.posts = None; Command::batch(vec! [ Command::perform(data::Post::fetch(id), Message::PostFound), Command::perform(data::Comment::fetch_for_post(id), Message::CommentsFound), ]) } Message::PostsFound(posts) => { match posts { Err(_) => (), Ok(data) => { self.posts = Some( data.into_iter() .map(|post| Post { detail_button: button::State::new(), post, }) .collect(), ); }}; Command::none() } Message::PostFound(post) => { match post { Err(_) => (), Ok(data) => { self.post = Some(Post { detail_button: button::State::new(), post: data, }); } } Command::none() } Message::CommentsFound(comments) => { match comments { Err(_) => (), Ok(data) => { self.comments = Some( data.into_iter() .map(|comment| Comment { comment }) .collect(), ); }}; Command::none() } } }Copy the code

This is quite a bit of code, but it’s also a core part of the logic aspect when it comes to our application, so let’s walk through it step by step.

First, we deal with GoToList. When you click on Home, it triggers. If this happens, we reset the save data for posts and comments, set route to List, and finally, trigger a request to fetch all posts.

In GoToDetail, we basically did the same thing, but this time we cleaned up the posts and issued a batch command — that is, to fetch the posts and comments for a given post ID.

Now it gets interesting. Processing Message::PostsFound(posts) occurs whenever fetch_all succeeds. Because we ignore the error, if we get an error, we don’t do anything, but if we get the data, we actually create a vector of the Post gadget with the returned data and set self.posts to the list of that gadget.

This means that when the data comes back, we are actually updating our application state. In this case, we return Command:: None (), which means the chain of Command ends there.

For PostFound and CommentsFound, the treatment is basically the same: we update the status of the app with widgets based on the data returned.

Finally, let’s take a look at the View function to see how we rendered our complete application.

fn view(&mut self) -> Element<Message> { let col = Column::new() .max_width(600) .spacing(10) .padding(10) .align_items(Align::Center) .push( Button::new(&mut self.list_button, Text::new("Home")).on_press(Message::GoToList), );  match self.route { Route::List => { let posts: Element<_> = match self.posts { None => Column::new() .push(Text::new("loading..." .to_owned()).size(15)) .into(), Some(ref mut p) => App::render_posts(p), }; col.push(Text::new("Home".to_owned()).size(20)) .push(posts) .into() } Route::Detail(id) => { let post: Element<_> = match self.post { None => Column::new() .push(Text::new("loading..." .to_owned()).size(15)) .into(), Some(ref mut p) => p.view(), }; let comments: Element<_> = match self.comments { None => Column::new() .push(Text::new("loading..." .to_owned()).size(15)) .into(), Some(ref mut c) => App::render_comments(c), }; col.push(Text::new(format! ("Post: {}", id)).size(20)) .push(post) .push(comments) .into() } } }Copy the code

First, we create another Column, this time with a maximum width, and put everything in the middle. This is our outermost container.

In this container, we added a Home button that, when clicked, triggers a GoToList message that takes us back to the Home page.

Then we go to self.route, where we route the state in the application.

If we’re on the List route, we check to see if we’ve set self.posts; Remember, when this is set, a request for the post has already been triggered. If not, we display a loading.. The information.

Once we have the data, we call App:: Render_posts, a helper program that actually renders the list of posts.

impl App {
    fn render_posts(posts: &mut Vec<Post>) -> Element<Message> {
        let c = Column::new();
        let posts: Element<_> = posts
            .iter_mut()
            .fold(Column::new().spacing(10), |col, p| {
                col.push(p.view_in_list())
            })
            .into();
        c.push(posts).into()
    }

    fn render_comments(comments: &Vec<Comment>) -> Element<Message> {
        let c = Column::new();
        let comments: Element<_> = comments
            .iter()
            .fold(Column::new().spacing(10), |col, c| col.push(c.view()))
            .into();
        c.push(Text::new(String::from("Comments:")).size(15))
            .push(comments)
            .into()
    }
}

Copy the code

There is also a corresponding helper for rendering the list of comments. In both cases, we simply iterate over the data and create a column that contains all the single posts and comment widgets.

Finally, we push the word Home and the list of returned Posts widgets to a column, adding them to the root column.

For the Detail route, we also check loading, and once the data is there, we put everything together and return it in a new column.

Test our Iced. Rs application

Now that we’ve finished our simple list/detail application, let’s test it out and see if it really works.

Let’s run Trunk Serve, which will build and run our application on http://localhost:8080.

Initially, we see a list of posts and their Detail links on the Home page.

Clicking on one of the links will take us to the detail page of the post, showing the body of the post and all the comments, which have been fetched in parallel from JSONPlaceholder.

It works!

You can find the full code for this example on GitHub.

conclusion

Having played Elm in the past and been familiar with Rust, building this little program with Iced. Rs is actually a very simple experience.

One thing that is immediately noticeable when it comes to networks using Iced. Rs is the lack of something basic, such as a mature routing library. It seems that the focus of Iced is not on the web, but on cross-platform GUI applications, in general.

Beyond that, examples and documentation are helpful. There is already an active community studying and using Iced. Rs. For use cases that focus on cross-platform development, I can definitely see Iced. Rs as a strong contender in the future.

Iced. Rs Tutorial: How to build a simple Rust front-end network application, first appeared on the LogRocket blog.