grahammendick / navigation

Scene-Based Navigation for React and React Native
https://grahammendick.github.io/navigation/
Apache License 2.0
571 stars 40 forks source link

feat: support SF Symbols #777

Closed gerzonc closed 5 months ago

gerzonc commented 6 months ago

Usage

<TabBarItem image={{uri: 'rectangle.stack.fill'}} title="Albums" />

Screenshots

TabBarItem component

image

BarButton component

image

Issues

Closes #775

gerzonc commented 6 months ago

@grahammendick i still need to take a look into it on the NVBarButton, but you can take a look and tell me what you think of it

p.s. i kinda messed up the indentation šŸ™ˆ I'll fix it later

grahammendick commented 6 months ago

Hey @gerzonc thanks so much for the PR. I really appreciate it. I'm impressed you tackled both the old and the new architecture. I know how much work that is and I have the scars to prove it. Which architecture did you find it easier to work in?

What should the public api look like?

You added a new "systemName" prop but maybe we could use an existing prop instead? For example, we could use the "systemItem" prop. This could take either a system item name like "done" or an SF symbol like "homepodmini.fill".

You'd still need the new "systemName" prop to pass it to native. But this would only be used internally and not appear in the public api.

const sfSymbol = systemItem && systemItem !== 'done' && systemItem !== 'cancel'...
<NVTabBarItem
  systemItem={systemItem && !sfSymbol ? systemItem : ""}
  systemName={sfSymbol ? systemItem : ""}

This doesn't work if there are any SF Symbols whose name clash with a systemItem. Are there any?

Or we could even use the "image" prop. The React Native image prop already accepts bundled images so we could expand it to accept a "systemName". Something like this perhaps?

<TabBarItem image={{uri: 'systemName:/homepodmini.fill'}} />

Then you'd convert this into the "systemName" prop to send to native.

const sfSymbol = image?.uri?.startsWith('systemName:/');
<NVTabBarItem
  systemName={sfSymbol ? image.uri.substring(12) : ""}
  image={Image.resolveAssetSource(!sfSymbol ? image : undefined)}

Do you like any of these ideas?

Converting the systemName into an image on native

You made a really good choice to keep things simple by not allowing the user to configure the weight or scale. After watching the SF Symbols introduction it sounds like Apple will always choose the right weight and scale when an SF Symbol appears in a Tab Bar.

I think there's a great opportunity to simplify the native code because you shouldn't need to go any where near the configuration. Doesn't the following work?

self.tab.image = [UIImage imageNamed:systemName];

Testing

Would be good to know how thoroughly you've tested the different scenarios. One problem one that jumps out at me is dynamically switching from an image to an SF Symbol and back again. They're both fighting to update the "image" property of the UITabBarItem.

Here's an example of the way I'd test this from the Twitter sample.

const [change, setChange] = useState(false;
useEffect(() => {
  setTimeout(() => {
    setChange(!change);
  }, 2000);
);
<TabBarItem
  systemItem={change ? 'homepodmini.fill' : undefined}
  image={!change ? require('./home.png') : undefined}
gerzonc commented 6 months ago

Hi @grahammendick thanks so much for your feedback! Really appreciate it. I did find working with the old architecture more simple somehow, not sure if you share the same feeling. šŸ˜…

You added a new "systemName" prop but maybe we could use an existing prop instead? For example, we could use the "systemItem" prop. This could take either a system item name like "done" or an SF symbol like "homepodmini.fill".

I do agree that we might need to unify both (systemItem and systemName), but not so sure if either use one that we have already or to create a new one, which creates a new, more generic word that can handle both terms (would, of course, become a breaking change). Coming from native iOS, referring to UITabBarSystemItem elements equally as any generic systemNameImage (or viceversa) might be confusing, maybe not the right direction? We could use system[Something] to represent both, not sure which word could be more appropriate, but I was thinking about systemSymbol or just symbol, not sure what you might think about it.

This doesn't work if there are any SF Symbols whose name clash with a systemItem. Are there any?

No, thankfully they managed this matter gracefully and none of them collide!

Or we could even use the "image" prop. The React Native image prop already accepts bundled images so we could expand it to accept a "systemName". Something like this perhaps?

I wanted to reach this part here to address what you mentioned earlier about combining props rather than having them separately properly (sorry, it has taken me a couple of hours to write this today). I was thinking about the possibility of this, since they all are trying to do the exact same thing: to display an element for each tab. For a SF Symbol or UITabBar.SystemItem, I was thinking we could probably define their type and validate if it's one thing or another with type guards internally and check what is it. So the API for the user would be something like:

SF Symbol

<TabBarItem symbol="house.fill" />

UITabBar.SystemItem

<TabBarItem symbol="bookmarks" />

Image

<TabBarItem symbol={{ uri: require('./home.png') }} />

While internally we could just type guard it and check if it's an image, systemItem or systemName to send it to native.

I think there's a great opportunity to simplify the native code because you shouldn't need to go any where near the configuration. Doesn't the following work?

Not sure, but I will definitely take a look.

Would be good to know how thoroughly you've tested the different scenarios.

Not much really, I wanted to first have a discussion with you since I'd like to move forward with your opinion on first hand, this first part do helps a lot into giving more insights, I would like to know how we move from here so we can establish a better API, but I will consider this test case during development.

Thank you so much for your feedback!

grahammendick commented 6 months ago

Hey, thanks for the really interesting response.

I find the old architecture much simpler, too. Iā€™m not sure I could've written navigation-react-native if there was only the new architecture around when I started.

Coming from native iOS, referring to UITabBarSystemItem elements equally as any generic systemNameImage (or viceversa) might be confusing

Thanks, thatā€™s very helpful for me. I donā€™t have a native background so I appreciate this insight.

I think that using the image prop for the SF Symbol makes sense because the SF Symbol is an image. I mean, in native iOS you create a UIImage from an SF Symbol name. Imagine that React Native adds support for SF Symbols. I'm pretty sure they'd used their existing Image component for that.

<Image source={{uri: 'systemName:house.fill'}} />

If React Native did add SF Symbol support this way, then we wouldn't need this PR. The Navigation router uses React Nativeā€™s image processing under the hood so the TabBar would get SF Symbol support for free.

Maybe we could use your symbol idea combined with the image prop?

<TabBarItem image={{uri: 'symbol:house.fill'}} />

That way it could also work for system items, too, and we could maybe deprecate the systemItem prop? I'm a bit less keen on that, though, because I donā€™t think system items are images. Not in the same way that SF Symbols are anyway. I mean you donā€™t create a UIImage from system items. Maybe Iā€™ve got that wrong though?

I'm looking forward to hearing what you think.

gerzonc commented 6 months ago

Hey, I see what you mean here.

I find the old architecture much simpler, too. Iā€™m not sure I could've written navigation-react-native if there was only the new architecture around when I started.

Hopefully they'll improve on that on the future!

I think that using the image prop for the SF Symbol makes sense because the SF Symbol is an image. I mean, in native iOS you create a UIImage from an SF Symbol name.

I understand why that might be confusing, yes using UIImage might make you think that the SF Symbol is an image, but reality is that they're a vector graphic set provided by the system. While you're essentially using UIImage, they're scalable vectors for you to use (you can take a look in here on usage). I would say they're closer to a font than an actual image component. This apply equally for UITabBar.SystemItem's, they pre-date SF Symbols, but are essentially the same on this. I do not discard the idea of using the image prop, though. Within this context, it makes sense.

grahammendick commented 6 months ago

That's super interesting, thanks.

You can already render svg's that are bundled into the app using React Native's Image component. You drag your svg into the asset catalog and then reference it by name. Isn't that the same as an SF Symbol?

<Image source={{uri: 'some_svg'}} />
gerzonc commented 6 months ago

You can already render svg's that are bundled into the app using React Native's Image component.

They're offered as part of the San Francisco font family so I think that's why it's not like that, they have a much more restricted set of customizations than SVGs. They even have an app to visualize the SF Symbols on macOS for you to check what kind of stuff you can do with them (like what animations you can use, which icon supports multicolor, combinations for building custom[1] symbols, etc.), you can take a look here

[1] I'd say more like "custom" because even that is predefined, you can't combine every icon with other every icon, there is a predefined set of icons that you can use in combination with others to build stuff like this for example:

image

Here I combined chart.line.uptrend.xyaxis with gearshape.fill, using gearshape.fill as a badge.

grahammendick commented 6 months ago

Here I combined chart.line.uptrend.xyaxis with gearshape.fill, using gearshape.fill as a badge.

Wow šŸ¤Æ

Another interesting thing is that SF Symbols fallback on iOS 12. For example, if you use 'gearshape.fill' then iOS 13 will show the SF Symbol but iOS 12 will show the matching named asset from the catalog. I think that suggests we should use the image prop. We don't even need the symbol: prefix

<TabBarItem image={{uri: 'gearshape.fill'}} />

And we probably don't need the "systemName" prop internally either. We can send the image prop to the native side as we do now.

<NVTabBarItem
  image={Image.resolveAssetSource(image}}

Then on the native side we can do something like this in the old architecture.

- (void)setImage:(RCTImageSource *)source
{
    if (!!source) {
        UIImage *sfSymbol = [UIImage systemName:source.request];
        if (sfSymbol) _tab.image = sfSymbol;
        else // load image the old way
    } else {
        _image = nil;
        _tab.image = nil;
    }

No doubt it will be trickier in the new architecture.

I don't think React Native supports iOS 12 so there's no point going this way if it's harder (it might be easier?). But does this suggest to you that we should be using the image prop for SF Symbols?

gerzonc commented 6 months ago

And we probably don't need the "systemName" prop internally either. We can send the image prop to the native side as we do now.

Yes šŸ˜ We can definitely try this! Thanks for the suggestion! Let's see how this goes.

gerzonc commented 6 months ago

Without the config now their sizes are more like regular native icons indeed. I'll continue fixing and cleaning the PR tomorrow, but here is a heads up!

image

Edit: Old sizes for reference:

image
grahammendick commented 6 months ago

Thanks for the heads up. It looks great ā¤ļø

I think it would make sense to have another chat after you've cleaned up the PR and before you move onto the new architecture? Then we can see how our plan is working out. What do you think?

gerzonc commented 6 months ago

Thanks for the heads up. It looks great ā¤ļø

I think it would make sense to have another chat after you've cleaned up the PR and before you move onto the new architecture? Then we can see how our plan is working out. What do you think?

Sure thing, what would you like to address? I was thinking about the possibility to add symbolEffect (the default animations supported for SF Symbols, take a look here) or TypeScript support, not sure what you might think of that (we can do a separate PR for that stuff since we're focusing on adding SF Symbols support first for the TabBarItem and BarButton)

grahammendick commented 6 months ago

Nope, I donā€™t want to make any additions to the plan. I just want to check that we have the same understanding around the implementation. I really appreciate the tremendous cleanup job youā€™ve done. It makes my life so much easier when the diff is minimal.

I wasnā€™t expecting to see TabBarItem.tsx show up in the diff. You shouldnā€™t need to make any changes to that. The plan is to send the image down exactly as now

<NVTabBarItem
  image={Image.resolveAssetSource(image}}

Then on the native side you can check whether itā€™s an sf symbol or not.

- (void)setImage:(RCTImageSource *)source
{
    if (!!source) {
        UIImage *sfSymbol = [UIImage systemName:source.request];
        if (sfSymbol) _tab.image = sfSymbol;
        else // load image the old way
    } else {
        _image = nil;
        _tab.image = nil;
    }
grahammendick commented 6 months ago

Doing it this way has a few advantages

I hope this helps but let me know if you think differently on any of this

gerzonc commented 6 months ago

Doing it this way has a few advantages

  • no changes to the public api <TabBarItem image={{uri: 'gearshape.fill'}} />
  • it's my best guess at how react native will add support for sf symbols one day
  • will automatically fallback to the asset catalog if the sf symbol doesn't exist just like iOS 12 would do
  • new architecture doesn't support different types for the same prop - so image can't be both a string and ImageSource

I hope this helps but let me know if you think differently on any of this

Simply doing this gives an error of stringByDeletingPathExtension (unrecognized selector sent to instance). While logging I figured that the source.request doesn't precisely sends the value that a systemImageNamed needs, take a look here:

Loading system name value, <NSURLRequest: 0x600000008f60> { URL: file:///Users/myUser/Library/Developer/CoreSimulator/Devices/9162BE1B-9431-4021-9CE3-EF3CB1E8X405/data/Containers/Bundle/Application/168C0A34-0ED8-4EF9-8446-DCB8FA2A89EC/twitter.app/home }

It adds an entire URL to it. For us to make it work like this with minimal changes to the API we need to trim it (we can't use directly an URL because otherwise natively UIImage doesn't know if it's an image or a system symbol). I ended up doing this instead:

if (!!source) {
        UIImage *sfSymbol;
        if (source.request) {
            NSURL *url = source.request.URL;
            NSString *systemNameImage = [url lastPathComponent];
            sfSymbol = [UIImage systemImageNamed:systemNameImage];
        }

        if (sfSymbol) {
            // Handle SF Symbol
            _image = sfSymbol;
            _tab.image = sfSymbol;
        } else {
            // Handle images
        }
    } else {
        _image = nil;
        _tab.image = nil;
    }
grahammendick commented 6 months ago

Ok, great, that looks good and thanks for the update. You don't need to check if source.request exists. If source is there then the request will be, too

if (!!source) {
  UIImage *sfSymbol = [UIImage systemImageNamed:[source.request.URL lastPathComponent]];
  if (sfSymbol) {
    _image = sfSymbol;
    _tab.image = sfSymbol;
  } else {
    // load image the old way
  }
gerzonc commented 6 months ago

Ok, great, that looks good and thanks for the update. You don't need to check if source.request exists. If source is there then the request will be, too

if (!!source) {
  UIImage *sfSymbol = [UIImage systemImageNamed:[source.request.URL lastPathComponent]];
  if (sfSymbol) {
    _image = sfSymbol;
    _tab.image = sfSymbol;
  } else {
    // load image the old way
  }

Done!

grahammendick commented 6 months ago

Awesome! I'm very happy with how it's shaping up.

If you're happy too then it makes sense to move onto the new architecture. I'm expecting you'll face issues because rendering images on the new architecture is much trickier. But you just let me know how you get on and I'll do my best to help.

gerzonc commented 6 months ago

Hey @grahammendick I managed to make it work but I get a "Could not find image file URL" every time the symbol's name is not extension formatted (e.g. "house.fill" works fine, but "house" brings this warning). I'm well aware my current implementation in Fabric is not the ~right~ definitive approach, can you please explain to me what's behind all this sorcery? (ImageResponseObserverCoordinator and RCTImageResponseObserverProxy).

Here is a screenshot of the SF Symbol loaded ("house") but it brought a warning

image
grahammendick commented 6 months ago

Iā€™m still pretty much in the dark about how images work on the new architecture. The best explanation I can give is the discussion I had with the React Native team when I was migrating the Navigation router to Fabric.

I think you need to put your logic into the updateState method instead of the updateProps (the code sample below is only a guess, I haven't tried it). Check if itā€™s an SF Symbol and only set the imageCoordinator if it isnā€™t. That should get rid of the warning youā€™re seeing because React Native wonā€™t try processing the image.

if (!havePreviousData || data.getImageSource() != _oldState->getData().getImageSource()) {
  UIImage sfSymbol = [UIImage systemImageNamed:data.getImageSource().uri];
  if (sfSymbol) {
      _image = sfSymbol;
      _tab.image = sfSymbol;
  } else {
    self.imageCoordinator = getCoordinator(&data.getImageRequest());
  }
}
gerzonc commented 5 months ago

Iā€™m still pretty much in the dark about how images work on the new architecture. The best explanation I can give is the discussion I had with the React Native team when I was migrating the Navigation router to Fabric.

I think you need to put your logic into the updateState method instead of the updateProps (the code sample below is only a guess, I haven't tried it). Check if itā€™s an SF Symbol and only set the imageCoordinator if it isnā€™t. That should get rid of the warning youā€™re seeing because React Native wonā€™t try processing the image.

if (!havePreviousData || data.getImageSource() != _oldState->getData().getImageSource()) {
  UIImage sfSymbol = [UIImage systemImageNamed:data.getImageSource().uri];
  if (sfSymbol) {
      _image = sfSymbol;
      _tab.image = sfSymbol;
  } else {
    self.imageCoordinator = getCoordinator(&data.getImageRequest());
  }
}

Sadly this won't work since the shadow node runs first and we use the Image Manager there to validate the URLs that we send to the native side, so we're just preventing it to be observed from the side of Objective-C, but from the C++ the app already knows that there is something odd with the URI. Here is the code:

void NVTabBarItemShadowNode::updateStateIfNeeded() {
  const auto &newImageSource = getImageSource();

  auto const &currentState = getStateData();

  auto imageSource = currentState.getImageSource();

  bool anyChanged = newImageSource != imageSource;

  if (!anyChanged) {
    return;
  }

  ensureUnsealed();

  auto state = NVTabBarItemState{
      newImageSource,
      imageManager_->requestImage(newImageSource, getSurfaceId()),
    };
  setStateData(std::move(state));
}

What I did was to create an overload of the constructor for NVTabBarItemState that accepts only the ImageSource and that fixed the issue, so now we know for sure that the issue comes from there. What is missing is how can we tell apart when we need to use it or not. What I was scrapping in my head is just building 2 regex that verify that is not a URL nor a file with extension (e.g .png, .jpg, etc.). If that's the case we just send the imageSource, otherwise we send the imageSource and the imageRequest.

What do you think of it? I'm really open to suggestions here.

gerzonc commented 5 months ago

Or better yet, we don't need any regex at all. We could just use std::string::find for those parts.

grahammendick commented 5 months ago

Really great progress.

We can't check whether it's a file or not in shadow node because we still want React Native to process the image if it's in the asset catalog. For example,

<Image source={{uri: 'some_svg'}} />

I think what you should do is move the SF Symbol check into the ShadowNode. So, if it's an SF Symbol use your overloaded constructor.

if ([UIImage systemImageNamed:newImageSource.uri]) {
    auto state = NVTabBarItemState{
        newImageSource,
      };
    setStateData(std::move(state));
}

Then in the updateState of the ComponentView we don't need the SF Symbol check anymore. You can just check if data.getImageRequest() is there or not.

if (!havePreviousData || data.getImageSource() != _oldState->getData().getImageSource()) {
      if (&data.getImageRequest()) {
          UIImage *systemSymbol = [UIImage systemImageNamed:[[NSString alloc] initWithUTF8String:data.getImageSource().uri.c_str()]]
          _image = systemSymbol;
          _tab.image = systemSymbol;
      }
gerzonc commented 5 months ago
if ([UIImage systemImageNamed:newImageSource.uri]) {

Is there a way we can create a Objetive-C++ wrapper for this to work? Straight into the ShadowNode won't work since we can't access UIKit there.

grahammendick commented 5 months ago

Oh no, that's annoying. I think I have a different way of doing it, but I don't really know why it works. I got the idea from the fact you had it working when the uri had a file extension and from something weird in the React Native source.

If the image has a uri and doesn't have a file extension then we could append .png to it in the TabBarItem React component.

if (typeof image === 'object' && image.uri.indexOf('.') === -1)
  image = {...image, uri: image.uri + '.png'}

Then in the updateProps you could remove the '.png' extension and check to see if it's a SF symbol. So you don't need to edit the shadow node stuff or use updateState at all.

if (![uri length]) {
    _image = nil;
    _tab.image = nil;
} else {
    UIImage *systemSymbol = [UIImage systemImageNamed:[[uri lastPathComponent] stringByDeletingPathExtension]];
    if (systemSymbol) {
        _image = systemSymbol;
        _tab.image = systemSymbol;
    }
}

This actually still works for named images in the asset catalog. If the image is named 'hello' then can create the image using [UIImage imageNamed:@"hello.png"] (that was the weird bit in the React Native code I mentioned).

I haven't tested this code but I think it'll work. I'm a bit uncomfortable with it because I don't think we can guarantee React Native won't start warning "Could not find image" for this scenario. I also don't know if it's guaranteed that asset catalog always allows access using a '.png' extension.

Really keen to hear what you think about this.

gerzonc commented 5 months ago

[UIImage imageNamed:@"hello.png"]

I figured that since the solution doesn't completely solve the "Could not find file" issue, this too could cause an issue in case I have an image in the asset catalog that collides with a SF Symbol (probably might render the SF Symbol instead of my asset?).

I know it took me a while, but I finally figured a way to use UIImage on the C++ side; I wasn't linking the headers and source and that's why it wasn't working all this time šŸ˜… I'll be updating the PR so you can take a look!

grahammendick commented 5 months ago

Wow! You didn't let the new architecture defeat you. I'm super impressed šŸ˜

I've got a few questions/suggestions

Thanks again for your hard work. The PR is shaping up so nicely

gerzonc commented 5 months ago

Your changes to the pbxproj don't look quite right. Did you add the NVUIKitWrapper.mm file as a target?

Removed!

I prefer you call the file NVSystemImageValidator or NVSFSymbolValidator (something like that)

I agree on this. In the long term if we need to keep adding stuff it make sense, but as of now, renaming it to something that adapts to our current terms is okay I believe.

Do we definitely need an overloaded NVTabBarItemState constructor. Can't we pass nullptr as the second parameter instead?

Nah, I figured my mistake on that. I removed the overload and passed an object to it.

I think it looks pretty good right now, I could move forward with the NVBarButton now if you agree; that can be more straightforward since we already went through all the issues to handle SF Symbol through all the layers.

grahammendick commented 5 months ago

It looks amazing. Very happy for you to start on the NVBarButton. Not expecting any surprises but you never know.

Could you explain why you settled on {newImageSource, nullptr, {}} as the second parameter, please? I'm sure you're right I just couldn't understand (I don't know any c++)

gerzonc commented 5 months ago

Could you explain why you settled on {newImageSource, nullptr, {}} as the second parameter, please? I'm sure you're right I just couldn't understand (I don't know any c++)

Our constructor expects an ImageRequest which is just an object of our ImageSource, ImageTelemetry and a cancellation function. I'm just satisfying this object since we can't directly set ImageRequest to be a nullptr (probably we could but that might end up us rewriting the constructor for NVTabBarItemState without needing to do so).

Since we're not running requestImage nothing in particular happens if we set it directly.

grahammendick commented 5 months ago

Oh gotcha, thanks šŸ™

gerzonc commented 5 months ago

Got it working straightforwardly! What would our next step be? I was thinking of expanding our type definitions for it. For instance, uri instead of being just a string, could add a union type with SFSymbols. This way, it can auto-complete not only for file paths but also for SF Symbols, which I believe would be a great addition. What do you think? Not sure at which extend that would be feasible with TypeScript, but still I'm willing try it out.

image

Usage demonstration with uri as visionpro and opticid on BarButton component

grahammendick commented 5 months ago

Really awesome. It's a special bit of work :fire:. I'm going to give it another review to see if we missed anything.

Your typescript idea sounds great. I'm excited to see what you come up with. But this PR is complete without it. So if it doesn't work out there's no problem.

gerzonc commented 5 months ago

@grahammendick i've made the changes for the types on the navigation/types folder but after running the npm run build and npm run package it does not generate the new types on the build/npm folder, is this a known issue? Or is there something else I have to run?

Edit: I have run npx tsc navigation-react-native.d.ts to check for issues and even after solving them it still does not bring the new types to the build/npm folder.

grahammendick commented 5 months ago

Thanks for the changes! You have to manually copy your type changes over to the build/npm folder :(

grahammendick commented 5 months ago

It looks like you've missed the ifdefs from the NVBarButtonShadowNode.cpp? Also, is the #if !ANDROID right? Shouldn't it be #ifndef ANDROID instead?

gerzonc commented 5 months ago

You have to manually copy your type changes over to the build/npm folder

Alright!

Also, is the #if !ANDROID right? Shouldn't it be #ifndef ANDROID instead?

It looks like that's the right way to do it for using operators like bang operator or any other kind of operator, otherwise it complains about the identifier not being found

gerzonc commented 5 months ago

Usage example šŸ™ˆ

image
gerzonc commented 5 months ago

@grahammendick I believe it is complete now. Please have a look and let me know if there is anything else to change!

grahammendick commented 5 months ago

It looks amazing and thanks for updating the PR description.

I've got to run through some manual testing.

grahammendick commented 5 months ago

Iā€™ve been trying to break it. I havenā€™t managed to yet :) But I did notice a couple of things

Screenshot 2024-04-04 at 16 28 27

gerzonc commented 5 months ago

Do you think we should fix it like this?

Yeah, I was getting it at the beginning of the PR but then never again; I believe is a good practice to have it so I'll just do it.

grahammendick commented 5 months ago

Don't forget to add the iOS 13 check to NVSystemImageValidator, too

gerzonc commented 5 months ago

Don't forget to add the iOS 13 check to NVSystemImageValidator, too

Done!

grahammendick commented 5 months ago

Thank you so much for your hard work on this amazing PR ā¤ļø . Iā€™ve really enjoyed working with you and hope weā€™ll work together again.

Iā€™ll thank you formally in the release notes.

grahammendick commented 5 months ago

I just released navigation-react-native v9.14.0 and thanked you in the release notes