software-mansion / react-native-screens

Native navigation primitives for your React Native app.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

LargeHeader stays small after pop/goBack/swipe gesture on iOS 14+

hirbod opened this issue · comments

Hi,

I just upgraded to Expo SDK 39 and before of that, I had never an issue navigating to a child screen (from LargeHeader to small header). When I go back now, it stays smalls instead of growing back to large.

What has changed and is this something known?

This looks like it has nothing to do with SDK 39, its an issue with iOS 14. It works on iOS 13.
Here is a video @WoLewicki https://streamable.com/x4vo01

The same code on iOS 13 will grow the large header back to its original size.

Yeah, I can spot problems with this. It looks like some kind of bug because it doesn't happen in the Settings of your phone. Am I right?

@WoLewicki you are right, works great everywhere else in the system.

The same here 👍

Does this behavior appear only while using ScrollView?

For me, it happens when using ScrollView and FlatList

Maybe this is related: iOS 14 Large title collapse when app run firstly.

I experience this issue too, when I start the app or scroll to the top, navigate and go back on iOS 14.

Can you check if #670 fixes the issue and does not introduce any new issues?

I’ve got the same problem on my Navigation router (@WoLewicki your fix didn’t work for me).

I’ve worked out a fix but we need to tweak the RCTScrollContentView class in React Native. We must ensure that updateContentOffsetIfNeeded first runs after the ScrollView is moved to the window. So we prevent it running inside reactSetFrame if there’s no window yet and get it to run from didMoveToWindow instead.

Here’s the fixed RCTScrollContentView. Can you check it works for you, please? You can open your project in XCode and paste this code into RCTScrollContentView

@implementation RCTScrollContentView

- (void)didMoveToWindow
{
    [super didMoveToWindow];
    RCTScrollView *scrollView = (RCTScrollView *)self.superview.superview;
    [scrollView updateContentOffsetIfNeeded];
}

- (void)reactSetFrame:(CGRect)frame
{
  [super reactSetFrame:frame];

  RCTScrollView *scrollView = (RCTScrollView *)self.superview.superview;

  if (!scrollView || !self.window) {
    return;
  }

  RCTAssert([scrollView isKindOfClass:[RCTScrollView class]], @"Unexpected view hierarchy of RCTScrollView component.");

  [scrollView updateContentOffsetIfNeeded];
}

@end

Looks like it works @grahammendick 🎉. Are you going to submit a PR with this in the react-native repository?

@WoLewicki I'm glad it works for you too!
I don't really like submitting PRs in the react-native repo. It takes me a while to set everything up to run locally and then they never look at the PR anyway. I'm happy for someone on this issue to submit the PR instead if they want

I agree with @grahammendick on this. If you're an unknown name in the original react-native repo, PRs won't get a merge in years. Super frustrating to submit something there. I don't know how many times I tried to get their (facebook) eyes on broken scroll-to-top behaviour on inverted scrollviews.

I think, you guys from SWM have a better reputation and are more likely to have success submitting something there.

Is there a chance to fix this trough react-native-screens instead of fixing it upstream? @WoLewicki

I tried fixing it upstream in my Navigation router but had no luck. The problem is we need to stop the updateContentOffsetIfNeeded running in reactSetFrame because that's too early. You can see what I mean by commenting out the !self.windowcheck in the fix and see it doesn't work anymore

@hirbod You're right, but the react-native repo is just one example of the problems you have if you're an unknown name in React. I've found it impossible to get my work looked at. If you're not a name then you're not getting in

@WoLewicki can someone from your team create PR to react-native repository?

It would need more debugging, submitting a PR with changes to a component used by almost every developer must have a very strong explanation and must ensure that no right behavior will be broken after it.

For me, this quick hack worked.

return(

      <FlatList
           contentOffset={{ x: 0, y: -1}}
                contentInsetAdjustmentBehavior={"always"}
                maintainVisibleContentPosition={
                    {

                        autoscrollToTopThreshold: -1,
                        minIndexForVisible: -1
                    }
                }
       > 
          {/*your content goes here */}
       </FlatList>
)

@DasArthur in my test cases, it looks like there is some race condition and it sometimes loads initially a large header and sometimes a small one. Can you check this solution @ArekChr @grahammendick ?

I remember trying a variety of contentOffset and contentInset and repeatedly reloading - sometimes it worked and sometimes it didn't. My guess is that the times it worked is when the reactSetFrame was called after it was added to the window - @DasArthur Could you check if the timing of the reactSetFrame changes when it works and when it doesn't? Or does it always work for you every time you reload?

I don't have maintainVisibleContentPosition prop, @DasArthur what version of RN are you using?

The workaround of @alexanderstrom seems to be the only reliable one. This is where you conditionally render the ScrollView after a delay (useEffect with a timeout of 0). I think it works for the same reason that my fix works - the delayed render means that didMoveToWindow runs before reactSetFrame. It runs as soon as it’s added as a subview because the parent is already in the window.

@grahammendick still feels so hacky :/

@hirbod I think another option is you could use patch-package to apply the fix to RCTScrollContentView

@grahammendick unfortunately not an option for me, because I'm still on Expo and I don't want to eject. Since I only have 3 LargeTitle Screens, I'll try the delay render useEffect hack.

@hirbod You can see that I'm using it in the zoom example app in my Navigation router

Weird, for me it was simply enough to add this:
My LargeHeader works as expected again. Also not nice, but better then the effect hack imho

        <>
            <View></View>
            <FlatList>...
        </>

So just empty brackets surrounding and an empty view before my Scrollview.

Ohhh... ok, my hack "works" for the back button, but it won't collapse when I scroll now. Dang it!

None of those workarounds working for me, even delaying the Scrollview render @grahammendick. I don't have any INITIAL issues with the LargeHeader, it is working for me always, its just when navigating to a stack-screen and returning. The header stays small for me after I pop. Only my mentioned "workaround" fixes this, but will break collapsing on scrolling :/

@hirbod Ok, my fix probably doesn't work for you either, then. Can you temporarily eject from expo, apply the fix and let me know if it works, please?

@grahammendick I tested it on iOS so far and it seems to work well! Thank you.

Here the patch react-native+0.63.3.patch for patch-package:

diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m
index cf6a0b1..4d187db 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m
@@ -14,13 +14,20 @@
 
 @implementation RCTScrollContentView
 
+- (void)didMoveToWindow
+{
+    [super didMoveToWindow];
+    RCTScrollView *scrollView = (RCTScrollView *)self.superview.superview;
+    [scrollView updateContentOffsetIfNeeded];
+}
+
 - (void)reactSetFrame:(CGRect)frame
 {
   [super reactSetFrame:frame];
 
   RCTScrollView *scrollView = (RCTScrollView *)self.superview.superview;
 
-  if (!scrollView) {
+  if (!scrollView || !self.window) {
     return;
   }

I used the useIsFocused() hook from react navigation

For doing what?

Is there any update on this for expo users?

same issus from expo users

Just for record I applied the @grahammendick patch, it works for initial rendering, but not for back

@grahammendick I can also confirm that I've made a temp eject and your applied patch did not fix the grow to large after a "pop / back". Still stays small.

@hirbod @fdecampredon I applied the patch to the zoom sample in my Navigation router and it works for both initial rendering and after a pop/back. Give the sample a try and it might give you a clue to what's different about React Navigation?

@pouyarezvani you have to use patch-package library for this

Doe anyone find out how to fix this issue without touching native code as @grahammendick 's fix?

I just ejected project to Bare Workflow and apply @grahammendick 's patch, it's not working.

For me, it happens when using ScrollView and FlatList

I'd like to add SectionList so it's likely a <VirtualizedList> issue. I just referenced this on Stack Overflow I didn't know about this bug. https://stackoverflow.com/questions/68477361/how-do-i-make-headerlargetitle-appear-as-large-title-when-the-component-is-a-fla

It appears there's a solution in the Apple Forums but I guess that requires ejecting. https://developer.apple.com/forums/thread/660745?answerId=640771022#640771022

Invoke the following method from your viewWillAppear()

navigationController?.forceUpdateNavBar()

and create this method in UINavigationController extension

extension UINavigationController {
func forceUpdateNavBar() {
DispatchQueue.main.async {
self.navigationBar.sizeToFit()
}
}
}

@trajano There is a PR implementing this, but it is a workaround of the issue rather than solution, so it can introduce other, not easy to track problems, so it probably won't be merged: #670

@grahammendick I tested it on iOS so far and it seems to work well! Thank you.

Here the patch react-native+0.63.3.patch for patch-package:

diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m
index cf6a0b1..4d187db 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m
@@ -14,13 +14,20 @@
 
 @implementation RCTScrollContentView
 
+- (void)didMoveToWindow
+{
+    [super didMoveToWindow];
+    RCTScrollView *scrollView = (RCTScrollView *)self.superview.superview;
+    [scrollView updateContentOffsetIfNeeded];
+}
+
 - (void)reactSetFrame:(CGRect)frame
 {
   [super reactSetFrame:frame];
 
   RCTScrollView *scrollView = (RCTScrollView *)self.superview.superview;
 
-  if (!scrollView) {
+  if (!scrollView || !self.window) {
     return;
   }

This worked for me in 0.63 and 0.64, but after upgrading to 0.65 I am getting a build time error due to this patch:
ARC semantic issue
No visible @interface for 'RCTScrollView' declares the selector 'updateContentOffsetIfNeeded'
If I disable the patch, the app builds but the big header problem comes back (the header starts in collapsed mode). Is there a solution for 0.65?

@aryanm5 updateContentOffsetIfNeeded changed to updateContentSizeIfNeeded in 0.65, below the patch react-native+0.65.1.patch:

diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m
index 8006540..a2080ff 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m
@@ -14,13 +14,20 @@
 
 @implementation RCTScrollContentView
 
+- (void)didMoveToWindow
+{
+  [super didMoveToWindow];
+  RCTScrollView *scrollView = (RCTScrollView *)self.superview.superview;
+  [scrollView updateContentSizeIfNeeded];
+}
+
 - (void)reactSetFrame:(CGRect)frame
 {
   [super reactSetFrame:frame];
 
   RCTScrollView *scrollView = (RCTScrollView *)self.superview.superview;
 
-  if (!scrollView) {
+  if (!scrollView || !self.window) {
     return;
   }

@aryanm5 Looks like react native has renamed updateContentOffsetIfNeeded to updateContentSizeIfNeeded.

Are you interested in creating this patch as a pull request on react native so that this problem doesn't happen again? Looks like react native are looking at pull requests again so might be worth giving it a go.

@aryanm5 Looks like react native has renamed updateContentOffsetIfNeeded to updateContentSizeIfNeeded.

Are you interested in creating this patch as a pull request on react native so that this problem doesn't happen again? Looks like react native are looking at pull requests again so might be worth giving it a go.

Sure. I'll give it a go and post updates here.

@aryanm5 Thanks for stepping up. I really appreciate it

@grahammendick @andreacassani @WoLewicki The PR was reviewed and phillip had some questions: facebook/react-native#32135 (comment)

He also asked:

  • have we tested that this doesn't regress behavior on < iOS 14? is this fix needed for iOS 15? can we add those to the test plan as well?
  • in terms of code change, can we add an OS version check on this since we only want to update this behavior on iOS >=14?

If any of you could help me out on these that would be appreciated. I'll edit the PR to include the additional information that phillip requested.

Hey @aryanm5,

You can point Phillip at my zoom sample. It’s a reproduction of the large title problem patched with the fix in the PR. If he removes the !self.window check then the fix won’t work anymore (the large title starts small). I don’t know why it doesn’t work. I just discovered the fix by playing around.

From my testing, we don’t need the fix on iOS 15. @aryanm5, can you check large titles work for you on iOS 15 without the fix, please?

Yes, a version check for iOS 14 makes sense.

@grahammendick Thank you for your input. I can confirm that the large titles work for me on iOS 15 without the fix. This is what I came up with to add the iOS 14 version check. Does it look good to you? I created an osVersion variable and made all the changes conditional to a check for iOS 14. It works as expected when I test it. I don't have much experience with the language so I'm not sure if this is the best way.

diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m
index 8006540..2c73bf4 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollContentView.m
@@ -14,13 +14,28 @@
 
 @implementation RCTScrollContentView
 
+int osVersion = nil;
++ (void)initialize {
+    if(osVersion == nil)
+        osVersion = [[[UIDevice currentDevice] systemVersion] intValue];
+}
+
+- (void)didMoveToWindow
+{
+    [super didMoveToWindow];
+    if (osVersion == 14) {
+      RCTScrollView *scrollView = (RCTScrollView *)self.superview.superview;
+      [scrollView updateContentSizeIfNeeded];
+    }
+}
+
 - (void)reactSetFrame:(CGRect)frame
 {
   [super reactSetFrame:frame];
 
   RCTScrollView *scrollView = (RCTScrollView *)self.superview.superview;
 
-  if (!scrollView) {
+  if (!scrollView || osVersion == 14 && !self.window) {
     return;
   }

@aryanm5 Hey, your best bet is to make your changes on the PR so Phillip can advise you of how to add the version check. But I can understand you might feel under pressure putting your code before the React Native review process. My objective-c isn't great but here's how I would've done it.

- (void)didMoveToWindow
{
  [super didMoveToWindow];
    
  if (@available(iOS 14.0, *)) {
    if (@available(iOS 15.0, *)) {
    } else {
      RCTScrollView *scrollView = (RCTScrollView *)self.superview.superview;
      [scrollView updateContentOffsetIfNeeded];
    }
  }
}

- (void)reactSetFrame:(CGRect)frame
{
  [super reactSetFrame:frame];

  if (@available(iOS 14.0, *)) {
    if (@available(iOS 15.0, *)) {
    } else {
      if (!self.window) {
        return;
      }
    }
  }

  RCTScrollView *scrollView = (RCTScrollView *)self.superview.superview;

  if (!scrollView) {
    return;
  }

  RCTAssert([scrollView isKindOfClass:[RCTScrollView class]], @"Unexpected view hierarchy of RCTScrollView component.");

  [scrollView updateContentOffsetIfNeeded];
}

Also, make sure the formatting is consistent (use 2 spaces throughout). I wasn't bothered about this when I was patching the package, but now you're making a PR the code has to be consistent.

commented

iOS 15 fixed the bug I've reported initally but introduced a new one. The large header is now snapping to a small header for me as soon as I scroll, looking super weird. (Expo SDK 43, iOS 15, iPhone 13 Pro Max)

https://streamable.com/x57lig

commented

@WoLewicki is the new introduced bug something this package can solve? Or is this still a RN Upstream issue? I really don't know how to proceed with large header if its basically broken :/

@hirbod do you have knowledge if such behavior happens in other libraries doing kind of the same thing, like react-native-navigation or @grahammendick Navigation router ? And if so, did they come up with a solution?

commented

@WoLewicki unfortunately not. I never tried another library. I can only tell you that it doesn’t happen in other system apps or any app I am using.

It is probably another issue of react-native's ScrollView and large header. Could you post here a quick reproduction of the newest problem?

@hirbod @WoLewicki The Navigation router doesn't have any problem with large headers on iOS 15. You could run my zoom sample and compare view hierarchies?

iOS 15 fixed the bug I've reported initally but introduced a new one. The large header is now snapping to a small header for me as soon as I scroll, looking super weird. (Expo SDK 43, iOS 15, iPhone 13 Pro Max)

https://streamable.com/x57lig

I am having the exact same issue as @hirbod on iOS 15; glad I found this thread. I'm using react-native-screens@3.9.0 with @react-navigation/native-stack@6.2.2 and react-native@0.64.3. I'll try to make a repro.

It is probably another issue of react-native's ScrollView and large header. Could you post here a quick reproduction of the newest problem?

@WoLewicki Here is a reproduction:

Video

iOS 14 (works) iOS 15
Screen.Shot.-.28.November.2021.mp4
RPReplay_Final1638128423.MP4

Code

Repo here: https://github.com/nandorojo/large-title-bug

Click to view code
import React from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { NavigationContainer } from "@react-navigation/native";

const { Navigator, Screen } = createNativeStackNavigator();

function Home() {
  return (
    <ScrollView>
      {new Array(40).fill(0).map((_, i) => (
        <View style={[styles.item, i % 2 && styles.odd]}>{i}</View>
      ))}
    </ScrollView>
  );
}

export default function App() {
  return (
    <NavigationContainer>
      <Navigator>
        <Screen
          name="Home"
          component={Home}
          options={{
            headerLargeTitle: true,
          }}
        />
      </Navigator>
    </NavigationContainer>
  );
}

const styles = StyleSheet.create({
  item: {
    height: 100,
    backgroundColor: "#41b87a",
  },
  odd: {
    backgroundColor: "#333333",
  },
});
commented

@nandorojo thanks for providing a repro for the issue. My desk was overloaded and I haven’t managed to create one!

@nandorojo the reproduction seems to be the same issue as the old ones with ScrollView and was resolved by this: #320 (comment). I checked the repro and it works correctly when applying those props. Can you check if it fixes your issue? And if so, is there something more to be done about it?

commented

I don’t like the fact we need to set special props in order to make it work. It wasn’t like this till iOS 13, broken in iOS 14 and now semi working on iOS 15 (but working under special circumstances when changing header and Scrollview).

Is there a way to make the DX better? I mean there is also no documentation about this.

I am afraid these options are the defaults used by native apps on iOS and are required for correct behavior of ScrollView integrated with large header.

commented

@WoLewicki Then I guess this issue can be closed right? Since it's not actually a bug?

@sallar yeah, I also think there is not much to be added from the react-native-screens side. I will close it then, feel free to comment here if something is wrong and we can always reopen this.

For me, this quick hack worked.

return(

      <FlatList
           contentOffset={{ x: 0, y: -1}}
                contentInsetAdjustmentBehavior={"always"}
                maintainVisibleContentPosition={
                    {

                        autoscrollToTopThreshold: -1,
                        minIndexForVisible: -1
                    }
                }
       > 
          {/*your content goes here */}
       </FlatList>
)

actually, for me, just adding contentInsetAdjustmentBehavior="automatic" to my flat list did the trick

Unfortunately, this is still an issue for me. None of the solutions in this thread have fixed this behavior. The problem seems to stem from hiding the parent navigators header and then navigating to the child screen with largeHeaderTitle set to true.

@branaust you may have to bite the bullet a bit on this one and create your own header/navigator yourself. I had to do that on my project and spent quite a bit of time trying to get the nuances of the animation correct. However, the header behaviour is predictable after that. I am just revisiting the idea again and posted to SO https://stackoverflow.com/questions/74957469/can-a-custom-header-in-react-navigation-receive-the-scrollview-ref-in-a-createst to see if there's a saner approach than what I did.

However, if you do create your own you'd have to remember the Pan Gesture to support swipe left or right on the stack or down to minimize a modal.

@trajano I did find a bit of a hacky workaround that someone else suggested in another thread. The solution consists of rendering the scrollView after the screen has been mounted.

Here's my screen:

const [renderScrollView, setRenderScrollView] = useState<boolean>(false);

  useEffect(() => {
    setTimeout(() => {
      setRenderScrollView(true);
    }, 100);
  }, []);

and here is the return:

const Content = () => {
    return (
      <Container
        style={{
          flex: 1,
          justifyContent: 'center',
          alignItems: 'center',
          backgroundColor: theme.colors.background,
        }}></Container>
    );
  };

  return !renderScrollView ? (
    <Content />
  ) : (
    <ScrollView
      style={{ backgroundColor: theme.colors.background }}
      contentInsetAdjustmentBehavior="automatic"
      contentContainerStyle={{
        flex: 1,
      }}>
      <Content />
    </ScrollView>
  );
};

This solution does fix the largeHeaderTitle issue and now every time I navigate to the screen the large header is shown by default.

In my special use case, I'm also rendering the native search bar in the header that react-navigation offers out of the box.
The solution I provided above does not fix the auto-collapsed search bar. Instead, user needs to scroll up once screen is rendered in order for it to appear in view.

My workaround for this was to add:

headerSearchBarOptions: {
              hideWhenScrolling: false,
            }

Now, when the screen is mounted, the large header works perfectly & search bar is visible (although it is always in view even when the user scrolls which is fine for now).

@branaust what if you did

const setRenderScrollViewToTrue = useCallback(()=>setRenderScrollView(true));
...
<Component onLayout={setRenderScrollViewToTrue} />

Would you eliminate the "timeout"?

commented

Unfortunately, this is still an issue for me. None of the solutions in this thread have fixed this behavior. The problem seems to stem from hiding the parent navigators header and then navigating to the child screen with largeHeaderTitle set to true.

Probably not the answer you are looking for and an unpopular opinion, but if you're early in the development I can only recommend not to use React Native 😄. Use Ionic or native development instead... RN comes with tons of dependencies and people tend to use a lot of libraries. Whenever an update comes out either for the OS or React Native pretty much everything breaks. I previously tried realizing a large app project with React Native and more time was spent on maintaining, forking, manually patching libraries and investigating than the usage of RN saved in time... And nowadays in most cases web apps like Ionic feel quite native as well and save A LOT of time in development. RN is just something in between (web and native) but not doing any of both really well.

@dioncodes I somewhat agree with you, but I also came from the hell of Ionic v2->v3->v4->aborted v5 upgrades which require me to restructure my entire code base to get things working. Working on React Native is actually less painful once you have control of the dependencies (which takes a bit of time but I think more fruitful), but Ionic upgrades require project structure changes which means you'd have to know what everyone has done and migrate over.

NativeScript would've been a good option if HMR with Vue worked.

Vue Native has been deprecated. Their team has focus has moved to NativeBase.

The primary selling point for React Native for me is still Expo Go and the snacks which allows me to quickly prototype UIs and show without huge drama. Without Expo, React-Native is too much of a pain to work with alone.

As far as managing dependencies learning from experience, whenever we use any 3rd party dependency, we have to think of how to get rid of it asap or merge their code base into our own using yarn workspaces (haven't gotten that far myself, what I've done is clone and push to our internal npmjs with updated package.json once we verify it works correctly in the current versions of React(native). The issue also appears with Ionic though not as much because Angular tends to control everything for you much like Vue.

@branaust what if you did

const setRenderScrollViewToTrue = useCallback(()=>setRenderScrollView(true));
...
<Component onLayout={setRenderScrollViewToTrue} />

Would you eliminate the "timeout"?

Works like a charm. Thank you!

commented

@trajano Yeah, Expo and the previews are definitely a good point with RN. However, you also get these with Ionic or web based frameworks, because you can already preview everything in your browser including HMR... The upgrade paths for Ionic weren't that well either in history, ngl. But it's less of an issue to stick to a previous Ionic version because most is still compatible to newer OS as there are less native components. I have maintained some apps that are running in latest OS versions on a code base that is 3 years old without any migrations so far.

One more reason why I stopped working with RN was that there were a few bugs with native libraries (including this react-native-screens) that no solutions existed for yet. Issues and comments were all over Github (including the official RN repo, just take a look at how many unsolved issues there are). And fixing them on your own would be a lot of work and require knowledge of the libraries or RN itself including its ancient Objective-C code. Maybe it was bad luck but during my larger RN project I stumbled in so many issues that were so time intense to solve, that it would have been much quicker to build a completely native app.

Anyways I'm also not a big fan of React anymore, especially since v3 Vue became so much better and that's already another reason for me not work with RN. And Vue Native isn't a reasonable option either (as you already mentioned). I'll just stick to Vue Ionic if Android is necessary and Swift + SwiftUI otherwise. In the end everything other from completely native development is a compromise. However native Android development is such a PITA that at least for Android some web/hybrid solution is necessary (if the platform needs to be supported at all) and dedicated/separate apps for iOS and Android require a higher budget...