feat(Stack v5, iOS): Support icon loading in header items and menu#4277
Conversation
There was a problem hiding this comment.
Pull request overview
Adds iOS Stack v5 support for rendering icons in header items and menu elements, including synchronous (SF Symbols, xcassets) and async (image/template sources) loading via an image-loader abstraction.
Changes:
- Adds
iconprop to iOS header items and menu items/submenus on the JS side, including asset-source resolution forrequire()images. - Introduces native icon data/mapping/resolution and wires async image loading via an
RNSImageLoadingprotocol backed byRCTImageLoader. - Updates iOS header/menu coordinators to rebuild UI when icon loads or menu state changes; adds an iOS SFT scenario covering the new behavior.
Reviewed changes
Copilot reviewed 35 out of 35 changed files in this pull request and generated 17 comments.
Show a summary per file
| File | Description |
|---|---|
| src/fabric/gamma/stack/StackHeaderItemIOSNativeComponent.ts | Adds icon to the Fabric native component props. |
| src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts | Public iOS header config types include icon with docstring. |
| src/components/gamma/stack/header/ios/StackHeaderMenu.ios.types.ts | Adds icon to menu item/submenu types with docs. |
| src/components/gamma/stack/header/ios/StackHeaderItem.ios.types.ts | Adds icon to header item props. |
| src/components/gamma/stack/header/ios/StackHeaderItem.ios.tsx | Resolves require() sources via Image.resolveAssetSource and recursively resolves menu icons. |
| src/components/gamma/stack/header/index.ts | Re-exports PlatformIconIOS for consumers of the header API. |
| ios/helpers/image/RNSImageLoading.h | New protocol abstraction for loading images from JSON sources. |
| ios/gamma/stack/screen/RNSStackScreenHeaderCoordinator.mm | Passes image loader into item/menu building so icons can resolve. |
| ios/gamma/stack/screen/RNSStackScreenHeaderCoordinator.h | Adds imageLoader property to coordinator. |
| ios/gamma/stack/header/RNSStackHeaderMenuMapper.mm | Parses icon field for menus and menu items. |
| ios/gamma/stack/header/RNSStackHeaderMenuData.mm | Stores icon in menu/menu-item data objects. |
| ios/gamma/stack/header/RNSStackHeaderMenuData.h | Adds icon property and init params for menu/menu-item data. |
| ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.mm | Resolves icons into UIMenu / UIAction images; invalidates menu on icon load. |
| ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.h | Updates menu API to accept image loader + invalidation callback. |
| ios/gamma/stack/header/RNSStackHeaderItemDataProviding.h | Exposes icon on header-item data provider protocol. |
| ios/gamma/stack/header/RNSStackHeaderItemComponentView.mm | Maps JS icon prop into native icon data and triggers updates. |
| ios/gamma/stack/header/RNSStackHeaderItemComponentView.h | Exposes icon on the component view. |
| ios/gamma/stack/header/RNSStackHeaderIconResolver.mm | New resolver for SF Symbols / xcassets (sync) and image sources (async). |
| ios/gamma/stack/header/RNSStackHeaderIconResolver.h | Resolver interface for icon resolution with async completion. |
| ios/gamma/stack/header/RNSStackHeaderIconMapper.mm | New mapper from dictionary to native icon data. |
| ios/gamma/stack/header/RNSStackHeaderIconMapper.h | Mapper interface. |
| ios/gamma/stack/header/RNSStackHeaderIconData.mm | New icon data object implementation. |
| ios/gamma/stack/header/RNSStackHeaderIconData.h | New icon data object interface + icon-type enum + resolved-image cache. |
| ios/gamma/stack/header/RNSStackHeaderContentFactory.mm | Applies resolved icons to UIBarButtonItem and uses placeholder image during async loads. |
| ios/gamma/stack/header/RNSStackHeaderContentFactory.h | Updates content-factory API to accept an image loader. |
| ios/gamma/stack/header/RNSStackHeaderConfigComponentView.mm | Implements RNSImageLoading using RCTImageLoader retrieved from Fabric state. |
| ios/gamma/stack/header/RNSStackHeaderConfigComponentView.h | Declares RNSImageLoading conformance. |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.h | Adds imageLoader weak pointer storage in state. |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.cpp | Implements set/getImageLoader. |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.h | Adds setImageLoader and mutable-state accessor. |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.cpp | Wires setImageLoader into state data. |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigComponentDescriptor.h | Injects RCTImageLoader from context into shadow node state (non-Android). |
| apps/src/tests/single-feature-tests/stack-v5/test-stack-header-icon-ios/scenario-description.ts | New SFT scenario metadata for header/menu icons. |
| apps/src/tests/single-feature-tests/stack-v5/test-stack-header-icon-ios/index.tsx | New SFT scenario UI to cycle icon types and validate updates. |
| apps/src/tests/single-feature-tests/stack-v5/index.ts | Registers the new iOS icon SFT scenario. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| weakBarButtonItem.image = image; | ||
| }]; | ||
| barButtonItem.image = syncImage ? syncImage : [UIImage imageWithCIImage:[CIImage emptyImage]]; |
There was a problem hiding this comment.
I was not able to think of anything better. If you remove the empty image fallback, the attached test will show that the item resizes when image loads
There was a problem hiding this comment.
When we add support for tinting, we should update the test to check that templateSource is tinted correctly (how about nested menus here?) and imageSource is not.
|
|
||
| NS_ASSUME_NONNULL_BEGIN | ||
|
|
||
| typedef NS_ENUM(NSInteger, RNSStackHeaderIconType) { |
There was a problem hiding this comment.
I wonder if we should create a shared enum for both tabs and stack icons. But this can be refactored some other time.
There was a problem hiding this comment.
that's for another time, yes
|
|
||
| @protocol RNSImageLoading <NSObject> | ||
|
|
||
| - (void)loadImageFromJsonSource:(NSDictionary *)jsonSource |
There was a problem hiding this comment.
This json relies on react-native and is not defined here in any way so it's not the best separation but I guess that for us it's okay.
On Android I handled image loading a bit differently, header config is fully responsible for loading images and providing Drawable (UIImage equivalent) to native impl. But I'm not sure which approach is better.
There was a problem hiding this comment.
Yeah, I think it's a "good enough" separation for now. I think I could make a better separation but would probably do it in a separate PR
| } from './StackHeaderMenu.ios.types'; | ||
| import { Image, NativeSyntheticEvent, StyleSheet } from 'react-native'; | ||
|
|
||
| function resolveIconAssetSources( |
There was a problem hiding this comment.
This looks like a general helper, at some point, we may consider moving it to utilities and sharing across components - outside of the scope of this PR
d46eb35 to
5014e91
Compare
…4295) ## Description This PR adds sizing calculations for placeholder image size so that placeholders for images that are loaded asynchronously are not causing layout shifts. ## Changes - added a method `RNSStackHeaderIconResolver.placeholderImageForIcon` that builds an empty image of target image's size and used for item and menu rendering ## Before & after - visual documentation | Before | After | | --- | --- | | <video src="https://github.com/user-attachments/assets/b3b401ea-d22c-4063-9383-a1deda1b0b02" /> | <video src="https://github.com/user-attachments/assets/0cedf8dd-021f-4afe-a3e3-82c69e96568c" /> | | <video src="https://github.com/user-attachments/assets/3d4cd395-95ae-4656-894c-abe812fa8572" /> | <video src="https://github.com/user-attachments/assets/58836fbc-b737-486e-8dd5-d003ddc11981" /> | ## Test plan Use test-stack-header-item-ios SFT ## Checklist - [ ] Included code example that can be used to test this change. - [ ] For visual changes, included screenshots / GIFs / recordings documenting the change. - [ ] For API changes, updated relevant public types. - [ ] Ensured that CI passes
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1615
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1180
Description
This PR adds support for symbols and images in header items and menus. This includes sfSymbols, xcassets, imageSources and templateSources. Values can be passed via
icondefining an object with contents depending on selected icon type. For image loading, RCTImageLoader is used, with protocol layer so that non-RN loader could theoretically be used, too.Changes
iconprop both for header items and menu items, with docstring. StackHeaderItem resolves require() asset sources via Image.resolveAssetSource before passing to native. Menu element icons are recursively resolved.RNSStackHeaderIconDataandRNSStackHeaderIconMapperfor mapping folly::dynamic dictionary to native objectRNSStackHeaderIconResolverfor wrapping any class that conforms toRNSImageLoadingprotocol, with cachingmenuToggleCallbacktomenuInvalidatedCallbacksince it now fires for more than menu toggleImportant
Async loading images (
imageSource,templateSource) means that for a brief time, icon might be missing. If we leave the field to be nil, UIKit automatically takes the item label and tries to animate it which results in a horrible glitching. To avoid this, I'm setting a blank image until the actual image finishes loading.Important
The above blank image workaround is not complete; we should create one that has the same size as the image being loaded - this shows in menus and iOS 18 items where there is no minimal width that iOS 26 bubble has. I've created another PR for this since I'm not sure we want to proceed with it as it currently is: #4295
Before & after - visual documentation
stack-v5-icons.mov
Note
Crossfade between image source and template source doesn't work currently. I'm not sure we'll be able to make it work. It only works correctly when I set a placeholder to be a
systemImageNamed, but this obviously is not possible to have, because it shows some arbitrary icon for few frames.Test plan
Use
test-stack-header-icon-iosSFT.Checklist