Addressing UIScrollView's Optimization Problems
Let's talk about overcoming the optimization issues for the very useful UIScrollView for iOS development.
Join the DZone community and get the full member experience.
Join For FreeUIScrollView is an incredibly useful control for iOS development, so it's not surprising that a huge number of articles have been written about ways to increase the overall performance of UIScrollView and UITableView. These articles range from recommendations on initializing a descendant view to complex ways to perform asynchronous loading and content preprocessing. However - even keeping in mind the long amount of time this topic has been researched - with each and every new project, new optimization problems arise that are related to UIScrollView and its descendants.
Today, we're going to talk about exactly those kinds of problems. We're also going to talk about the solutions that have been created as programmers — both at Distillery and elsewhere — have worked to eliminate and mitigate those problems. After all, ensuring a smooth user experience (UX) is crucial to ensuring the success of any web or mobile app.
Squaring the Circle: Rendering UIImage in UIImageView
In the majority of modern mobile apps, almost every UITableView cell contains various images (e.g., the user's avatar, a photo for a post, or a video thumbnail). By using high-resolution images, however, you create a bottleneck in the scrolling operation. In addition to the problem of the useless loading of a relatively large amount of information via a mobile network, you're constantly bothered with the problem of UIScrollView rendering. UIImage goes through several independent stages before the user is able to see the final image on their device's screen. These stages include:
- UIImage data initialization
- Image data decompression
- Rendering unpacked data in CGContext, scaling, smoothing, and performance of other similar actions
At the same time, the decompression and rendering processes are delayed (lazy) in this context - i.e., they are performed at the moment of real data access, and not during the initialization of UIImage. Moreover, when that UIImage is assigned to the UIImageView image property, all of the abovementioned stages are performed in the UI thread, causing various lags and jumps of the UIScrollView content during scrolling.
Solution 1: Asynchronous Image Rendering and Client-Side Resizing
The first solution consists of the asynchronous rendering of UIImage in CGContext before adding it to UIImageView. At the very same time, it seems pretty logical to change the resolution of the image by using the real size of UIImageView, caching an image that's already processed and ready to use.
DispatchQueue.global(qos: .background).async {
let imageAspectRatio = image.size.height / image.size.width
let imageSize = CGSize(width: imageView.frame.width,
height: imageView.frame.width * imageAspectRatio)
UIGraphicsBeginImageContextWithOptions(imageSize, true, UIScreen.main.scale)
image.draw(in: CGRect(origin: .zero, size: imageSize))
let newImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
DispatchQueue.main.async {
imageView.image = newImage
}
}
Such an approach enables an increase in overall scrolling performance; however, it also increases the delay between the moment of image loading initialization and its visual representation on the screen of the device. That's why this solution is the best choice when the app has to communicate with a third-party service. If the server-side is developed in-house, however, the next solution (Solution 2) is both more efficient and more highly recommended.
Solution 2: Resizing Images Server-Side
In this solution, the size of the content is changed right on the server, allowing receipt of a ready-to-use image on the client-side. There are several ways to implement this solution:
- Creating an asset of the source image that includes several copies of the original image with different resolutions. This action must be performed on the server after the upload is complete. After that, you need to create a convention between all the platforms that includes the content of the asset and information about the basic link along with a postfix in order to display the correct resolution. For example, let's imagine you have the following basic link: https://ownbackend.com/image-100500. Here's the link with a postfix used to get the content in a specific resolution: https://ownbackend.com/image-100500-width100.
- Striping the image on the server-side "on the fly" during the processing of the request. In this case, you have to define parameters that represent the size of the requested content (e.g., https://ownbackend.com/image-100500?width=100&height=200).
- Combining the first and the second approaches (i.e., by striping the image "on the fly" and caching new images so that it can send it without delays when it receives similar requests).
Each of these methods has its own limitations related to the free memory and the time to process. Thus, you should make your selection in accordance with the computing power of the server equipment. It's worth noting that one of the most flexible, convenient, and scalable solutions is the use of third-party services, described in Solution 3.
Solution 3: Using Third-Party Content Management Services
In this solution, you use a special service to perform the required operations. For the project discussed in this article, our team used Cloudinary. Cloudinary is a platform created to manage content for mobile and web applications, offering cloud storage with the admin panel as well as numerous tools for video and image editing. By using this service, our team managed to implement the following model:
- There are no assets created when the image is uploaded to the server.
- The client-side receives a link to the basic image (e.g., https://ownbackend.com/image-100500), where the last component represents the image's unique identifier.
- The client-side created a link to load the image using Cloudinary. The final resolution is calculated by using the scale coefficient of the display and is defined in pixels (not points).
w_100 - defines the required width h_200 - defines the required height c_thumb - defines image centering by detecting the face and putting it into the centervar imageView: UIImageView! ... func generateThumbnailUrl(sourceUrl: URL, width: CGFloat, height: CGFloat) -> URL? { let filename = sourceUrl.lastPathComponent let scale = UIScreen.main.scale let newWidth = Int(width * scale) let newHeight = Int(height * scale) return URL(string: cloudinaryBaseUrl + "/w_\(newWidth),h_\(newHeight),c_thumb/" + filename) } ... let thumbnailURL = generateThumbnailUrl(sourceUrl: originalImageURL, width: imageView.frame.width, height: imageView.frame.height)
- The processing of the image is performed "on the fly," while the final results of the editing are stored in the cloud. If the system receives similar requests in the future, it is able to use the previously saved image without performing any additional editing.
- The content is returned via CDN (Content Delivery Network or Content Distribution Network), thus increasing the overall loading speed.
By implementing this solution, our team managed to solve the problem of rendering big images as well as the problem inherent in the useless loading of heavy content. It also enabled flexible integration of the solution in both Android and web platforms. It's worth noting that such an approach also provides flexibility in the event that additional image sizes are required, as well as in the event that devices' appearance have significantly increased pixel density.
Shady Shadows: Rendering Shadows for UIView
By using shadows in the field of design, you can create a 3D effect that highlights specific elements. This approach is well-known and very popular. The most popular way of adding shadows to UIView is the following:
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOffset = CGSize(width: 0, height: 2)
cardView.layer.shadowOpacity = 0.5
cardView.layer.shadowRadius = 2
In the majority of cases, using the standard method of adding shadows to objects within the cell causes no problems. However, when the cell and subviews of UIScrollView become overloaded, such an approach decreases the fluidity of the scrolling experience. The issue is that the shadow is calculated by using the value of the alpha channel of each pixel of a specific UIView, decreasing frames per second (FPS) rendering.
Solution: Using shadowPath
To avoid these issues, you need to use shadowPath when adding the shadow. In doing so, you are able to render the shadow by using a specific shape and a gradient, which is significantly faster than the process of pixel-by-pixel calculation. In such a situation, you must add the following construction to the general configuration:
let path: CGPath = UIBezierPath(roundedRect: cardView.bounds,
cornerRadius: cardView.containerCornersRadius).cgPath
cardView.layer.shadowPath = path
cardView.layer.shouldRasterize = true
cardView.layer.rasterizationScale = UIScreen.main.scale
There are many different ways to initialize UIBezierPath, allowing creation of a specific path for UIView (e.g., with rounded corners, as in our example). It's worth noting that shadowPath must correspond with the size of UIView, which means you have to track size changes (e.g., by using the layoutSubviews() method).
Views That Fail at Hiding: Rendering Shadows for UIView (Hidden Version)
The next problem in UIScrollView performance appeared during tests on the iPhone 6 Plus/6s Plus/7 Plus, which showed decreased fluidity while scrolling. The Core Animation Instrument confirmed a decrease in FPS from the average value of 55 down to 35 on the displays that had problems showing the content. While searching for a solution, the team discovered that the problem is well-known for the devices mentioned, and that a way to eliminate it is to disable the transparency in iOS settings. As a result, the team had to understand the real effect of the transparency and views shadows of the device. We found out that the existence of shadows of views - which are not displayed on the screen at the moment and are not subviews of UIScrollView - decreased the speed of the rendering of visible elements.
Solution 1: Disabling Shadows
The first solution is to disable shadows when UIView is not visible by setting a "0" value for the shadowOpacity parameter. Though this solution is the easiest and most convenient option when you have a clear system of visibility control, it's not really versatile.
Solution 2: Using shadowPath
The second option is to use shadowPath for shadows, which makes rendering much easier. You may face additional requirements, however, in UIView responsiveness, animation, and other operations related to the coordinates' transformation because you have to support UIBezierPath changes.
Scrolling Content WHILE Scrolling Content: Embedding UIScrollView and UITableView
Another task we faced and solved during this project was the implementation of embedded UIScrollView to create navigation similar to that used in Medium, Twitter, and IMDb. We had to create a header with the general information, a dashboard with changeable tabs (fixed on the top of the screen during scrolling), as well as an area with a horizontal UIScrollView, which contains pages in the form of vertical tables. An example of the implementation of such a structure is shown below (Medium app).
According to the Apple Documentation, embedded scrolls are supported starting from iOS 3.0 onward. However, when we faced the reality presented by our project, we understood that "supported" actually meant "won't work without magic" in the kinds of cases described above. The obvious solution was to build a hierarchy shown on the image... which resulted in the following problems:
- The scrolling initiated in one UIScrollView is not transferred to another UIScrollView in the frames of one movement. In other words, if the speed of scrolling is high, the motion is sharply interrupted on the edge of the content without causing movement on the parent UIScrollView. The motion is transferred to the parent module only when the scrolling is started in an internal UIScrollView.
- If there are more than two levels of embedded content, the transfer of motion to the parent UIScrollView becomes simply impossible when bounce parameters are disabled. In other words, the scrolling happens only in UIScrollView, which was used to detect a gesture.
- If one sets bounce and alwaysBounceVertical parameters for UIScrollView to true, the behavior described in the first section becomes possible; however, it causes a huge number of negative effects, including sharp movements, jumping, the disappearance of the internal UITableView, and shifting of the content of the horizontal UIScrollView.
After conducting an intensive search, we managed to find a solution: You have to make the size of the UIScrollView frame equal to contentSize and disable bounce for the internal UIScrollView. Our second attempt was successful, because the solution genuinely works. However, there was still a problem in the form of a significant delay during the opening of the hierarchy controller. Internal UIScrollView is UITableView, which identifies the visibility of the cells by corresponding frame and contentSize. When these are equal, cell reuse doesn't work, so separate cells are created for all the content being initialized by the data - and those cells stay in the memory of the device.
Solution: Using Containers
The way to eliminate this problem is thoroughly described in an article by Daniele Margutti called "Efficient Scrolling UIStackView in Swift." The main gist of the article is to follow these steps:
- Implement a delegate of the main UIScrollView - in particular, the scrollViewDidScroll(_ scrollView: UIScrollView) method.
- Put the visible content in views, which are containers. Containers must have frame.size equal to the size of the content, which allows calculation of the proper size and offset value of the main UIScrollView.
- Calculate the visible part of the table after each change of the offset of the parent UIScrollView.
- Keep the frame.size of the table less than or equal to the size of the device's display.
- Change the frame.origin and contentOffset of the internal UIScrollView in order to shift the visible part and make it correspond to the visible part of the parent UIScrollView.
It turns out that the user is able to interact only with the parent scroll, while all internal views are moved and transformed on the software level, allowing UITableView to properly calculate visible cells.
In the project described within this article, the horizontal UIScrollView served as a container for tables, changing their size in accordance with the currently visible UITableView. Similarly, after switching to a new UITableView, the system had to recalculate the position of the main UIScrollView subviews and change the size of the horizontal UIScrollView container. After that, the system had to switch the frame and contentOffset tracking to the currently shown UITableView.
Though we used this method for the vertical navigation, the key principles can also be applied if all basic elements are located horizontally.
Conclusion: Forewarned Is Forearmed
Even though UIScrollView and UITableView have relatively simple and understandable operating principles, problems of optimization are still apparent. They can appear even in the event of slight deviances in the standard flow-usage of the components. We know that — in the words of computer scientist Donald Knuth — "premature optimization is the root of all evil," but forewarned is forearmed. And anyway, such knowledge will undoubtedly save you both precious time and nerve cells.
Published at DZone with permission of Maria Kulkova, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments