Grow on iOS 26: Liquid Glass Adaptation in UIKit + SwiftUI Hybrid Architecture

Published on

Grow is a health management app that has received App Store Editor’s Choice recognition in 173 countries and regions, with over 180,000 five-star reviews. When adapting to iOS 26’s Liquid Glass design language, the team faced numerous challenges: How to implement native morph effects in a UIKit + SwiftUI hybrid architecture? How to precisely control Scroll Edge Effect? How to handle dynamic sizing of custom navigation bar elements?

I invited Shuhari, one of Grow’s developers, to share the team’s practical experience during this adaptation process. The article covers refactoring solutions for common scenarios like Sheet, Navigation, and Popover, delves into underlying details such as UIBarButtonItem size calculation and CABackdropLayer side effect handling, and demonstrates how to create a unique “glass text” effect using Core Text. All core concepts come with a complete Demo project.


As a health product that has been evolving since 2021, Grow strives to integrate interesting and refined elements into what would otherwise be mundane data dashboards through stylized design. These elements naturally impose higher requirements on code implementation—complex UI and interactions have become key challenges we need to address. From Storyboard/XIB in the project’s early stages to a hybrid architecture based on UIKit with SwiftUI as a functional supplement, Grow has continuously sought balance between design philosophy and technical implementation.

As mentioned above, Grow’s code structure is relatively traditional, based on UIKit design. Therefore, the adaptation experience listed below primarily targets UIKit or hybrid architecture applications and may not be fully applicable to pure SwiftUI architecture apps.

Since the eve of iOS 26’s release, Grow has been conducting technical research based on rumors and attempting to integrate with the new design paradigm. After WWDC25, the team quickly began refining Liquid Glass interactions and design, first establishing two basic adaptation principles:

  • Maintain the user experience for Grow users on older iOS versions unchanged
  • Bring familiar interactions but refreshing visual experiences to users upgrading to iOS 26

To achieve these two goals, we need to first understand the project’s current state. Let’s start with presentation sheet refactoring:

Sheet Refactoring

In Build a UIKit app with the new design, Apple clearly states that to fully showcase Liquid Glass’s glassy texture, all custom background content needs to be removed. For applications using APIs like sheet(isPresented:onDismiss:content:) or UISheetPresentationController, you simply need to use availability checks like #available(iOS 26.0, *) to differentiate background configurations for different system versions.

Presentation Sheet on both iOS18 and iOS26

Additionally, to control the edge blur effect (Scroll Edge Effect), you can use ToolbarItem(placement: .bottomBar) to activate the effect and .safeAreaInset(edge: .bottom) to disable it. For example, when you need to keep content clear at the bottom of the page, you can use the latter to cancel the blur.

Scroll Edge Effect

Grow uses UINavigationController to implement the navigation system, but early versions didn’t directly use UINavigationBar. Instead, we used custom UIView to host complex UI elements to simulate navigation bar functionality. Starting with the new version supporting iOS 26, we brought back the native UINavigationBar, hoping to unify the design language and improve development efficiency through Apple’s best practices.

Navigation on Today

The five Tab pages in Grow have similar structures—all display a navigation bar in a UINavigationController container and use scroll containers like UIScrollView at the bottom to present page content. Therefore, you only need to configure navigationItem and use Auto Layout to lay out the scroll container:

Swift
private func setupNavigationItems() {
    navigationItem.largeTitleDisplayMode = .never
    navigationItem.leftBarButtonItem = ...
    navigationItem.rightBarButtonItem = ...
}

private func setupCollectionView() {
    collectionView.translatesAutoresizingMaskIntoConstraints = false
                    
    NSLayoutConstraint.activate([
        collectionView.topAnchor.constraint(equalTo: view.topAnchor),
        collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
    ])
}

Implementing Morph Menu Effect with popoverPresentationController

To incorporate more Liquid Glass elements, we adjusted the date selection component on the “Insights” page from a sheet presentation to a custom popover menu form:

Insights

In UIKit, after laying out UI elements in UIViewController, we use popoverPresentationController and bind navigationItem.rightBarButtonItem to .sourceItem to achieve this effect:

Swift
private func setupCalendarButton() {
    let rootView = InsightsCalendarDatePickerButton(viewModel: calendarViewModel)
    let hostingController = UIHostingController(rootView: rootView)
    hostingController.view.backgroundColor = .clear
    hostingController.sizingOptions = [.intrinsicContentSize]
    
    let barButtonItem = UIBarButtonItem(customView: hostingController.view)
    navigationItem.rightBarButtonItem = barButtonItem
    
    calendarBarButtonItem = barButtonItem
}

private func calendarButtonTapped() {
    let popoverVC = InsightsCalendarDatePickerViewController(
        currentDate: currentMonth,
        minimumDate: minimumDate
    ) { [weak self] updatedDate in
        self?.calendarViewModel.updateMonth(using: updatedDate)
    }

    popoverVC.modalPresentationStyle = .popover
    popoverVC.popoverPresentationController?.sourceItem = calendarBarButtonItem
    popoverVC.popoverPresentationController?.delegate = self

    present(popoverVC, animated: true)
}

Since the UI width displayed on rightBarButtonItem for the finally selected date may vary, you need to set sizingOptions to [.intrinsicContentSize] when constructing UIHostingController, making it follow the root view’s content size.

If your application needs to implement similar effects in other scenarios using SwiftUI, you can refer to these two demos by Kavsoft:

Grow didn’t adopt these two implementation approaches for the following reasons:

  1. When bridging pure SwiftUI implementations through UIHostingController in UIKit-based navigation bars, data coordination and view positioning become quite complex.
  2. Navigation bar space is limited, and displaying child views that exceed the range typically requires handling complex responder chain issues.

Using UIPopoverPresentationControllerDelegate elegantly solves both problems:

Swift
func popoverPresentationControllerShouldDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) -> Bool {
    return true
}

func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) {
    if let presentedVC = popoverPresentationController.presentedViewController as? InsightsCalendarDatePickerViewController {
        presentedVC.view.layoutIfNeeded()
    }
}
  • popoverPresentationControllerShouldDismissPopover(_:) solves the response priority issue: when the popover is presented, clicking anywhere on the screen will prioritize closing the popover, except for manually calling dismiss(animated:).
  • prepareForPopoverPresentation(_:) allows us to process content before the popover is finally presented, such as content size calculation, resetting internal animation states, etc.

UIBarButtonItem Size Calculation in UIKit + SwiftUI Environment

Grow designed clear page titles on the left side of the navigation bar for some top-level Tab pages, and some titles also implement simple content transition animations through .contentTransition(.numericText()). Taking the Today page as an example, when users select different dates, date text of different lengths needs to be displayed:

Considering the need to configure this custom view on UIKit’s navigationItem, we may encounter the following two problems:

  1. Which framework should be used to support this functionality and transition animation?
  2. How to handle the dynamic width of date text in navigationItem?

Let’s solve them one by one:

The first problem is relatively simple. The most convenient way to use .contentTransition(.numericText()) is through SwiftUI View with injected ObservableObject, adjusting the currently selected Date data in the UIKit environment.

To handle the second problem, first construct the Text view wrapped by UIHostingController through UIBarButtonItem(customView:) and set it as navigationItem.leftBarButtonItem. Also remove the Liquid Glass effect on iOS 26: barButtonItem.hidesSharedBackground = true. Additionally, to ensure SwiftUI can correctly calculate content size and pass it to UIKit, you need to set the container’s sizingOptions to intrinsicContentSize. It will automatically update each time Text content changes and the view body recalculates.

Besides SwiftUI’s size settings, you also need to adjust constraint conditions on the UIKit navigation bar. Content configured through custom view on navigationItem doesn’t recalculate size based on content changes by default (note the orange background in the video below).

Obviously, the orange background in the video didn’t adjust with intrinsicContentSize, indicating it was compressed. We just need to increase the content compression resistance priority:

Swift
hostingController.view.backgroundColor = .systemOrange
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
hostingController.view.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)

The Liquid Glass Effect is Great, But I Want Control

When we call pushViewController(_:animated:), the default morph transition effect on iOS 26’s navigation bar produces side effects (unnatural animation of orange rectangle transitioning to circular back button):

To handle this problem, the key is to not configure any bar button item content on navigationItem. Since titleView can also receive UIView, we can try placing UIHostingController on navigationItem.titleView:

On navigationItem.titleView

Second step, we need to remove the compression resistance priority and instead adjust the Content Hugging Priority, setting it to .fittingSizeLevel in the horizontal direction, indicating this view is “willing to be expanded and stretched”:

Swift
hostingController.view.setContentHuggingPriority(.fittingSizeLevel, for: .horizontal)

On navigationItem.titleView with fittingSizeLevel

Finally, we just need to add content padding and left alignment modifiers to Text in SwiftUI to simulate a style similar to navigationItem.leftBarButtonItem. Then when calling pushViewController(_:animated:), there will be no more morph effect side effects:

Besides the morph effect produced by navigation bar push/pop, in some scenarios we also need conditional control over the Scroll Edge Effect of scroll views. A typical scenario is: when the top of the page has key visual elements like large images or maps, we usually don’t want them to be blurred. In the new version of Grow, we also made adjustments to the Challenge page:

Challenge

When entering the challenge detail page, we need to ensure the top image is clear, then trigger the Scroll Edge Effect after scrolling past the large title. In implementation, using UICollectionReusableView as a supplementary header view combined with content sections is a relatively conventional solution. But the problem is: both header and section belong to UICollectionView’s scrolling elements, and scrolling triggers the CABackdropLayer corresponding to top/bottom, causing the edge effect view to always be created:

To avoid this problem, we can make the supplementary header view independent as a view directly added to UICollectionView, without going through UICollectionViewCompositionalLayout for layout. Then set a contentInset.top equal to the header view’s height. This way, there’s no need to calculate how much offset to scroll before manually adjusting the hiding and showing of UIScrollEdgeElementContainerInteraction—as long as cells enter the navigation bar’s range, UIKit will automatically handle the Scroll Edge Effect:

Liquid Glass Shape

glassEffect(_:in:) provides a convenient way to apply Liquid Glass to View. In Grow, simply adjusting the style parameters of Glass can satisfy most scenarios. We noticed that the API’s second parameter accepts Shape type, which provides possibilities for further extension.

I wonder if anyone noticed that in the first version supporting iOS 26, we applied Liquid Glass effect to the title text at the top of the What’s New section:

By using CTFontCreatePathForGlyph in Core Text to convert glyphs to CGPath, then combining them into a glyph shape and passing it to glassEffect(_:in:) for shape rendering, we can obtain a transparent “glass text” material that’s not limited to pure geometric shapes and can have customized content.

Demo

To facilitate understanding and practice, I organized the above content into a demo project: Insights. The demo is organized around core concepts, each module contains complete implementation code, you can directly view the effects or integrate interesting parts into your own project. Code comments cover key implementation details for quick location and understanding.

It should be noted that this demo adopts a relatively experimental development approach, so some detail handling may not be rigorous enough. If you find problems or have improvement suggestions during use, feel free to contact me to discuss together.

Weekly Swift & SwiftUI highlights!