달력

11

« 2024/11 »

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
2021. 11. 11. 11:58

webview_flutter로 alert/confirm 띄우기 (iOS) 개발/Flutter2021. 11. 11. 11:58

반응형

이전에도 비슷한 주제로  글을 작성한 적이 있었는데 이번엔 해결법이 약간 다르다.

(이전 글 : https://risha-lee.tistory.com/40)

 

이번에는 상황이 Confirm 종류도 많았고,

그냥 웹뷰를 보여주는 앱이었으며
웹은 이미 작성된지 오래된 상태였고

Confirm 이후 분기 처리도 꽤 복잡한 편이어서 꼭 웹의 Confirm 기능을 써야만 했다.

 

일단 Android는 webview_flutter를 2.0.13으로 버전 업하니 alert가 기본으로 작동하게 되었는데 

iOS에서는 여전히 alert/confirm이 안뜨는 문제가 있었다.

찾아보니 역시나 webview_flutter 자체 지원은 안되고 파일 업로드 때 처럼 직접 소스 코드를 수정하기로 하였다.

 

1. 먼저 webview_flutter가 저장되는 위치를 찾는다.

나는 mac 환경이고 webview_flutter 버전은 2.0.13을 사용하였기 때문에

/Users/${userId}/.pub-cache/hosted/pub.dartlang.org/webview_flutter-2.0.13

여기에 저장되어 있었다.

 

2. webview_flutter-2.0.13/ios/Classes/FlutterWebView.m 의 맨 하단 @end 이전에

다음을 추가한다.

- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler{
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"" message:message?:@"" preferredStyle:UIAlertControllerStyleAlert];
  [alertController addAction:([UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
  completionHandler();
  }])];

    UIViewController *_viewController = [UIApplication sharedApplication].keyWindow.rootViewController;
  [_viewController presentViewController:alertController animated:YES completion:nil];
}

- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler{
  //    DLOG(@"msg = %@ frmae = %@",message,frame);
  UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"" message:message?:@"" preferredStyle:UIAlertControllerStyleAlert];
  [alertController addAction:([UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
    completionHandler(NO);
  }])];
  [alertController addAction:([UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
    completionHandler(YES);
  }])];

    UIViewController *_viewController = [UIApplication sharedApplication].keyWindow.rootViewController;
  [_viewController presentViewController:alertController animated:YES completion:nil];
}

 

 

runJavaScriptTextInputPanelWithPrompt라고 prompt 처리하는 것으로 보이는 것도 있긴한데

내가 작업하는 웹뷰에서 사용하지 않아서 확인하진 못하였으나 비슷한 방식으로 변환하면 다음과 같다.

- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable))completionHandler{
  UIAlertController *alertController = [UIAlertController alertControllerWithTitle:prompt message:@"" preferredStyle:UIAlertControllerStyleAlert];
  [alertController addTextFieldWithConfigurationHandler:^(UITextField * _Nonnull textField) {
    textField.text = defaultText;
  }];
  [alertController addAction:([UIAlertAction actionWithTitle:@"" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
    completionHandler(alertController.textFields[0].text?:@"");
  }])];
  UIViewController *_viewController = [UIApplication sharedApplication].keyWindow.rootViewController;
  [_viewController presentViewController:alertController animated:YES completion:nil];
}

 

 

 

참고 출처 : https://codertw.com/%E7%A8%8B%E5%BC%8F%E8%AA%9E%E8%A8%80/695084/

https://stackoverflow.com/questions/6131205/how-to-find-topmost-view-controller-on-ios

반응형
:
Posted by 리샤씨
2021. 11. 9. 12:52

webview_flutter로 file upload하기 (Android) 개발/Flutter2021. 11. 9. 12:52

반응형

최근 flutter로 웹뷰에서 파일 업로드해야 하는 일이 생겼는데 

Android webview_flutter(https://pub.dev/packages/webview_flutter)에서

그냥 input file 은 작동하지 않는 것을 확인하였다.

 

검색해보니 flutter_webview_plugin(https://pub.dev/packages/flutter_webview_plugin)를

이용하면 별도 작업없이 바로 되는 것 같아 보였다.

하지만 flutter_webview_plugin로는 이미 개발해둔 javasrcipt와 flutter의 통신

페이지 네비게이션 등 처리가 될 것 같지 않아보여서 다른 방법을 더 찾아보았다.

 

결국 직접적으로 webview_flutter의 소스를 건드는 방법을 알게되어 그 소스를 공유하도록 하겠다.

 

1. 먼저 webview_flutter가 저장되는 위치를 찾는다.

나는 mac 환경이고 webview_flutter 버전은 2.0.13을 사용하였기 때문에

/Users/${userId}/.pub-cache/hosted/pub.dartlang.org/webview_flutter-2.0.13

여기에 저장되어 있었다.

 

2. webview_flutter-2.0.13/android/src/main/AndroidManifest.xml를 아래 소스로 수정한다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android" 
    package="io.flutter.plugins.webviewflutter">
<application>
 <provider
     android:name="io.flutter.plugins.webviewflutter.GenericFileProvider"
     android:authorities="${applicationId}.generic.provider"
     android:exported="false"
     android:grantUriPermissions="true"
     >
     <meta-data
         android:name="android.support.FILE_PROVIDER_PATHS"
         android:resource="@xml/provider_paths"
         />
 </provider>
 <activity android:name="io.flutter.plugins.webviewflutter.FileChooserActivity">
 </activity>
 <activity android:name="io.flutter.plugins.webviewflutter.RequestCameraPermissionActivity">
 </activity>

 </application>
 <queries>
     <package android:name="com.google.android.apps.photos" />
     <package android:name="com.google.android.apps.docs" />
     <package android:name="com.google.android.documentsui" />
 </queries>
</manifest>

 

3. webview_flutter-2.0.13/android/src/main/res/values/strings.xml를 추가한다.

경로에 폴더가 없다면 폴더도 추가한다.

<resources>
    <string name="webview_file_chooser_title">Choose a file</string>
    <string name="webview_image_chooser_title">Choose an image</string>
    <string name="webview_video_chooser_title">Choose a video</string>
</resources>

 

4. webview_flutter-2.0.13/android/src/main/res/xml/provider_paths.xml를 추가한다.

경로에 폴더가 없다면 폴더도 추가한다.

 <paths xmlns:android="http://schemas.android.com/apk/res/android">
     <external-files-path name="safetyapp_images" path="images" />
 </paths>

 

5. webview_flutter-2.0.13/android/src/main/java/io/flutter/plugins/webviewflutter/Constants.java를 추가합니다.

package io.flutter.plugins.webviewflutter;

public class Constants {
    static final String ACTION_REQUEST_CAMERA_PERMISSION_FINISHED =
            "action_request_camera_permission_denied";
    static final String ACTION_FILE_CHOOSER_FINISHED = "action_file_chooser_completed";

    static final String EXTRA_TITLE = "extra_title";
    static final String EXTRA_ACCEPT_TYPES = "extra_types";
    static final String EXTRA_SHOW_VIDEO_OPTION = "extra_show_video_option";
    static final String EXTRA_SHOW_IMAGE_OPTION = "extra_show_image_option";
    static final String EXTRA_FILE_URIS = "extra_file_uris";
    static final String EXTRA_ALLOW_MULTIPLE_FILES = "extra_allow_multiple_files";


    static final String WEBVIEW_STORAGE_DIRECTORY = "images";
}

 

6. webview_flutter-2.0.13/android/src/main/java/io/flutter/plugins/webviewflutter/FileChooserActivity.java를 추가한다.

package io.flutter.plugins.webviewflutter;

import static io.flutter.plugins.webviewflutter.Constants.ACTION_FILE_CHOOSER_FINISHED;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_ACCEPT_TYPES;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_ALLOW_MULTIPLE_FILES;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_FILE_URIS;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_SHOW_IMAGE_OPTION;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_SHOW_VIDEO_OPTION;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_TITLE;
import static io.flutter.plugins.webviewflutter.Constants.WEBVIEW_STORAGE_DIRECTORY;

import android.app.Activity;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.core.content.FileProvider;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;

public class FileChooserActivity extends Activity {

private static final int FILE_CHOOSER_REQUEST_CODE = 12322;
private static final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd_HHmmss");

// List of Uris that point to files where there MIGHT be the output of the capture. At most one of these can be valid
private final ArrayList<Uri> potentialCaptureOutputUris = new ArrayList<>();

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    showFileChooser(
            getIntent().getBooleanExtra(EXTRA_SHOW_IMAGE_OPTION, false),
            getIntent().getBooleanExtra(EXTRA_SHOW_VIDEO_OPTION, false));
}

private void showFileChooser(boolean showImageIntent, boolean showVideoIntent) {
    Intent getContentIntent = createGetContentIntent();
    Intent captureImageIntent =
            showImageIntent ? createCaptureIntent(MediaStore.ACTION_IMAGE_CAPTURE, "jpg") : null;
    Intent captureVideoIntent =
            showVideoIntent ? createCaptureIntent(MediaStore.ACTION_VIDEO_CAPTURE, "mp4") : null;

    if (getContentIntent == null && captureImageIntent == null && captureVideoIntent == null) {
        // cannot open anything: cancel file chooser
        sendBroadcast(new Intent(ACTION_FILE_CHOOSER_FINISHED));
        finish();
    } else {
        ArrayList<Intent> intentList = new ArrayList<>();

        if (getContentIntent != null) {
            intentList.add(getContentIntent);
        }

        if (captureImageIntent != null) {
            intentList.add(captureImageIntent);
        }
        if (captureVideoIntent != null) {
            intentList.add(captureVideoIntent);
        }

        Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER);
        chooserIntent.putExtra(Intent.EXTRA_TITLE, getIntent().getStringExtra(EXTRA_TITLE));

        chooserIntent.putExtra(Intent.EXTRA_INTENT, intentList.get(0));
        intentList.remove(0);
        if (intentList.size() > 0) {
            chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.toArray(new Intent[0]));
        }

        startActivityForResult(chooserIntent, FILE_CHOOSER_REQUEST_CODE);
    }
}

private Intent createGetContentIntent() {
    Intent filesIntent = new Intent(Intent.ACTION_GET_CONTENT);

    if (getIntent().getBooleanExtra(EXTRA_ALLOW_MULTIPLE_FILES, false)) {
        filesIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
    }

    String[] acceptTypes = getIntent().getStringArrayExtra(EXTRA_ACCEPT_TYPES);

    if (acceptTypes.length == 0 || (acceptTypes.length == 1 && acceptTypes[0].length() == 0)) {
        // empty array or only 1 empty string? -> accept all types
        filesIntent.setType("*/*");
    } else if (acceptTypes.length == 1) {
        filesIntent.setType(acceptTypes[0]);
    } else {
        // acceptTypes.length > 1
        filesIntent.setType("*/*");
        filesIntent.putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes);
    }

    return (filesIntent.resolveActivity(getPackageManager()) != null) ? filesIntent : null;
}

private Intent createCaptureIntent(String type, String fileFormat) {
    Intent captureIntent = new Intent(type);
    if (captureIntent.resolveActivity(getPackageManager()) == null) {
        return null;
    }

    // Create the File where the output should go
    Uri captureOutputUri = getTempUri(fileFormat);
    potentialCaptureOutputUris.add(captureOutputUri);

    captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, captureOutputUri);

    return captureIntent;
}
private File getStorageDirectory() {
    File imageDirectory = new File(this.getExternalFilesDir(null), WEBVIEW_STORAGE_DIRECTORY);
    if (!imageDirectory.isDirectory()) {
        imageDirectory.mkdir();
    }
    return imageDirectory;
}


private Uri getTempUri(String format) {
    String fileName = "CAPTURE-" + simpleDateFormat.format(new Date()) + "." + format;
    File file = new File(getStorageDirectory(), fileName);
    return FileProvider.getUriForFile(
            this, getApplicationContext().getPackageName() + ".generic.provider", file);
}

private String getFileNameFromUri(Uri uri) {
    Cursor returnCursor = getContentResolver().query(uri, null, null, null, null);
    assert returnCursor != null;
    int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
    returnCursor.moveToFirst();
    String name = returnCursor.getString(nameIndex);
    returnCursor.close();
    return name;
}

private Uri copyToLocalUri(Uri uri) {
    File destination = new File(getStorageDirectory(), getFileNameFromUri(uri));

    try (InputStream in = getContentResolver().openInputStream(uri);
         OutputStream out = new FileOutputStream(destination)) {
        byte[] buffer = new byte[1024];
        int len;
        while ((len = in.read(buffer)) != -1) {
            out.write(buffer, 0, len);
        }
        return FileProvider.getUriForFile(
                this, getApplicationContext().getPackageName() + ".generic.provider", destination);
    } catch (IOException e) {
        Log.e("WEBVIEW", "Unable to copy selected image", e);
        e.printStackTrace();
        return null;
    }
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (requestCode == FILE_CHOOSER_REQUEST_CODE) {
        Intent fileChooserFinishedIntent = new Intent(ACTION_FILE_CHOOSER_FINISHED);
        if (resultCode == Activity.RESULT_OK) {
            if (data != null && (data.getDataString() != null || data.getClipData() != null)) {
                if (data.getDataString() != null) {
                    // single result from file browser OR video from camera
                    Uri localUri = copyToLocalUri(data.getData());
                    if (localUri != null) {
                        fileChooserFinishedIntent.putExtra(
                                EXTRA_FILE_URIS, new String[] {localUri.toString()});
                    }
                } else if (data.getClipData() != null) {
                    // multiple results from file browser
                    int uriCount = data.getClipData().getItemCount();
                    String[] uriStrings = new String[uriCount];

                    for (int i = 0; i < uriCount; i++) {
                        Uri localUri = copyToLocalUri(data.getClipData().getItemAt(i).getUri());
                        if (localUri != null) {
                            uriStrings[i] = localUri.toString();
                        }
                    }
                    fileChooserFinishedIntent.putExtra(EXTRA_FILE_URIS, uriStrings);
                }
            } else {
                // image result from camera (videos from the camera are handled above, but this if-branch could handle them too if this varies from device to device)
                for (Uri captureOutputUri : potentialCaptureOutputUris) {
                    try {
                        // just opening an input stream (and closing immediately) to test if the Uri points to a valid file
                        // if it's not a real file, the below catch-clause gets executed and we continue with the next Uri in the loop.
                        getContentResolver().openInputStream(captureOutputUri).close();
                        fileChooserFinishedIntent.putExtra(
                                EXTRA_FILE_URIS, new String[] {captureOutputUri.toString()});
                        // leave the loop, as only one of the potentialCaptureOutputUris is valid and we just found it
                        break;
                    } catch (IOException ignored) {
                    }
                }
            }
        }
        sendBroadcast(fileChooserFinishedIntent);
        finish();
    } else {
        super.onActivityResult(requestCode, resultCode, data);
    }
   }
 }

 

7. webview_flutter-2.0.13/android/src/main/java/io/flutter/plugins/webviewflutter/FileChooserLauncher.java를 추가한다.

package io.flutter.plugins.webviewflutter;

import static io.flutter.plugins.webviewflutter.Constants.ACTION_FILE_CHOOSER_FINISHED;
import static io.flutter.plugins.webviewflutter.Constants.ACTION_REQUEST_CAMERA_PERMISSION_FINISHED;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_ACCEPT_TYPES;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_ALLOW_MULTIPLE_FILES;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_FILE_URIS;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_SHOW_IMAGE_OPTION;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_SHOW_VIDEO_OPTION;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_TITLE;

import android.Manifest;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.webkit.ValueCallback;
import androidx.core.content.ContextCompat;

public class FileChooserLauncher extends BroadcastReceiver {

private Context context;
private String title;
private boolean allowMultipleFiles;
private boolean videoAcceptable;
private boolean imageAcceptable;
private ValueCallback<Uri[]> filePathCallback;
private String[] acceptTypes;

public FileChooserLauncher(
        Context context,
        boolean allowMultipleFiles,
        ValueCallback<Uri[]> filePathCallback,
        String[] acceptTypes) {
    this.context = context;
    this.allowMultipleFiles = allowMultipleFiles;
    this.filePathCallback = filePathCallback;
    this.acceptTypes = acceptTypes;

    if (acceptTypes.length == 0 || (acceptTypes.length == 1 && acceptTypes[0].length() == 0)) {
        // acceptTypes empty -> accept anything
        imageAcceptable = true;
        videoAcceptable = true;
    } else {
        for (String acceptType : acceptTypes) {
            if (acceptType.startsWith("image/")) {
                imageAcceptable = true;
            } else if (acceptType.startsWith("video/")) {
                videoAcceptable = true;
            }
        }
    }

    if (imageAcceptable && !videoAcceptable) {
        title = context.getResources().getString(R.string.webview_image_chooser_title);
    } else if (videoAcceptable && !imageAcceptable) {
        title = context.getResources().getString(R.string.webview_video_chooser_title);
    } else {
        title = context.getResources().getString(R.string.webview_file_chooser_title);
    }
}

private boolean canCameraProduceAcceptableType() {
    return imageAcceptable || videoAcceptable;
}

private boolean hasCameraPermission() {
    return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
            == PackageManager.PERMISSION_GRANTED;
}

public void start() {
    if (!canCameraProduceAcceptableType() || hasCameraPermission()) {
        showFileChooser();
    } else {
        IntentFilter intentFilter = new IntentFilter();
        intentFilter.addAction(ACTION_REQUEST_CAMERA_PERMISSION_FINISHED);
        context.registerReceiver(this, intentFilter);

        Intent intent = new Intent(context, RequestCameraPermissionActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startActivity(intent);
    }
}

private void showFileChooser() {
    IntentFilter intentFilter = new IntentFilter(ACTION_FILE_CHOOSER_FINISHED);
    context.registerReceiver(this, intentFilter);

    Intent intent = new Intent(context, FileChooserActivity.class);
    intent.putExtra(EXTRA_TITLE, title);
    intent.putExtra(EXTRA_ACCEPT_TYPES, acceptTypes);
    intent.putExtra(EXTRA_SHOW_IMAGE_OPTION, imageAcceptable && hasCameraPermission());
    intent.putExtra(EXTRA_SHOW_VIDEO_OPTION, videoAcceptable && hasCameraPermission());
    intent.putExtra(EXTRA_ALLOW_MULTIPLE_FILES, allowMultipleFiles);
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    context.startActivity(intent);
}

@Override
public void onReceive(Context context, Intent intent) {
    if (intent.getAction().equals(ACTION_REQUEST_CAMERA_PERMISSION_FINISHED)) {
        context.unregisterReceiver(this);
        showFileChooser();
    } else if (intent.getAction().equals(ACTION_FILE_CHOOSER_FINISHED)) {
        String[] uriStrings = intent.getStringArrayExtra(EXTRA_FILE_URIS);
        Uri[] result = null;

        if (uriStrings != null) {
            int uriStringCount = uriStrings.length;
            result = new Uri[uriStringCount];

            for (int i = 0; i < uriStringCount; i++) {
                result[i] = Uri.parse(uriStrings[i]);
            }
        }

        filePathCallback.onReceiveValue(result);
        context.unregisterReceiver(this);
        filePathCallback = null;
    }
  }
 }

 

8. webview_flutter-2.0.13/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java

class FlutterWebChromeClient안에 메소드를 추가한다.

import android.net.Uri;
import android.webkit.ValueCallback;


    @Override
    public boolean onShowFileChooser(
            WebView webView,
            ValueCallback<Uri[]> filePathCallback,
            FileChooserParams fileChooserParams) {
      // info as of 2021-03-08:
      // don't use fileChooserParams.getTitle() as it is (always? on                Mi 9T Pro Android 10 at least) null
      // don't use fileChooserParams.isCaptureEnabled() as it is (always? on Mi 9T Pro Android 10 at least) false, even when the file upload allows images or any file
      final Context context = webView.getContext();
      final boolean allowMultipleFiles = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
              && fileChooserParams.getMode() == FileChooserParams.MODE_OPEN_MULTIPLE;
      final String[] acceptTypes = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
              ? fileChooserParams.getAcceptTypes() : new String[0];
      new FileChooserLauncher(context, allowMultipleFiles, filePathCallback, acceptTypes)
              .start();
      return true;
    }

 

9. webview_flutter-2.0.13/android/src/main/java/io/flutter/plugins/webviewflutter/RequestCameraPermissionActivity.java를 추가합니다.

package io.flutter.plugins.webviewflutter;

import android.Manifest; 
import android.app.Activity; 
import android.content.Intent; 
import android.os.Bundle; 
import androidx.annotation.NonNull; 
import androidx.annotation.Nullable; 
import androidx.core.app.ActivityCompat;
import static io.flutter.plugins.webviewflutter.Constants.ACTION_REQUEST_CAMERA_PERMISSION_FINISHED;

public class RequestCameraPermissionActivity extends Activity {

    private static final int CAMERA_PERMISSION_REQUEST_CODE = 12321;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        ActivityCompat.requestPermissions(
                this, new String[] {Manifest.permission.CAMERA}, CAMERA_PERMISSION_REQUEST_CODE);
    }

    @Override
    public void onRequestPermissionsResult(
            int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) {
            sendBroadcast(new Intent(ACTION_REQUEST_CAMERA_PERMISSION_FINISHED));
            finish();
        } else {
            super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        }
    }
}

 

10. webview_flutter-2.0.13/android/src/main/java/io/flutter/plugins/webviewflutter/GenericFileProvider.java를 추가한다.

package io.flutter.plugins.webviewflutter;

import androidx.core.content.FileProvider;

public class GenericFileProvider extends FileProvider {

    public static final String PROVIDER_NAME = "io.flutter.plugins.webviewflutter.GenericFileProvider";

}

 

11. input file 클릭시 카메라로 직접 사진을 찍어 파일을 올리게 하려면 원래 flutter project에서 android/app/src/main/AndroidManifest.xml 에 <uses-permission android:name="android.permission.CAMERA"/> 권한을 추가한다. (선택)

 

카메라 권한 확인 팝업 / 파일 선택 팝업

 

 

 

출처 :

https://stackoverflow.com/questions/60289932/is-there-a-way-of-making-a-flutter-webview-use-android-camera-for-file-upload-h
https://github.com/pubnative/easy-files-android/blob/master/app/src/main/java/net/easynaps/easyfiles/utils/GenericFileProvider.java

https://m.blog.naver.com/websearch/221738662288

 

 

 

 

반응형
:
Posted by 리샤씨
반응형

며칠 전, Flutter에서 webview_flutter(https://pub.dev/packages/webview_flutter)로 띄운 소스에서

alert가 보이지 않으니 해결해달라는 요청을 받아 처리하게 되었습니다.

찾아보니 webview_flutter는 alert&confirm을 지원하지 않는다고 합니다.

flutter와 html에서 약속을 해서 새로운 function을 만들고 호출하는 방법도 있지만,

(참조: https://cording-cossk3.tistory.com/103)

저는 html 개발하시는 분과 직접 소통하는 것도 아니고, 최대한 네이티브 소스는 건드리지 않기 위하여

그냥 기존에 있는 alert를 flutter의 toast로 작업하는 방식으로 처리하였습니다.

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:toast/toast.dart';
import 'package:webview_flutter/webview_flutter.dart';

class WebviewPage extends StatelessWidget {
  WebViewController _webViewController;

  @override
  void initState() {
    if (Platform.isAndroid) WebView.platform = SurfaceAndroidWebView();
  }

  @override
  Widget build(BuildContext context) {
    return WebView(
      initialUrl: '${yourUrl}',
      javascriptMode: JavascriptMode.unrestricted,
      javascriptChannels: <JavascriptChannel>{
        _alertJavascriptChannel(context),
      },
      onWebViewCreated: (WebViewController webViewController) {
        _webViewController = webViewController;
      },
      onPageFinished: (url) async {
        try {
          var javascript = '''
                             window.alert = function (e){
                               Alert.postMessage(e);
                             }
                           ''';
          await _webViewController?.evaluateJavascript(javascript);
        } catch (_) {}
      },
    );
  }

  JavascriptChannel _alertJavascriptChannel(BuildContext context) {
    return JavascriptChannel(
        name: 'Alert',
        onMessageReceived: (JavascriptMessage message) {
          Toast.show(message.message, context,
              duration: Toast.LENGTH_LONG,
              gravity: Toast.CENTER,
              backgroundColor: Colors.black38,
              backgroundRadius: 5);
        });
  }
}

이 방법으로 하면 confirm도 처리되긴 합니다.

그런데 confirm이 하나일땐 크게 문제 없겠지만 (위와 같이 confirm을 정의해서 flutter 내에서 dialog 호출해서 처리)

여러 개일때는 confirm마다 기능이 다르다면 결국 html 개발자와 같이 상의해서 개발해야 할 것입니다.

confirm도 처리해야 할 때는 android는 webview_flutter를 2.0.13 버전 이상으로 업 하면 해결됩니다.

ios는 다음 글을 참고하여 처리하여 주세요. (다음 글 : https://risha-lee.tistory.com/42)

 

 

참고 출처 : https://github.com/flutter/flutter/issues/30358

 

 

 

 

 

 

 

반응형
:
Posted by 리샤씨
2021. 8. 12. 20:53

Flutter에서 카카오톡 채널 추가하기 개발/Flutter2021. 8. 12. 20:53

반응형

Flutter를 한지 반년 이상 지났지만, 바쁘기도 했고 보통 pub.dev에서 갖다썼기 때문에

마땅히 포스팅할게 없어서 이제서야 관련 글을 작성합니다.

 

최근, 카카오톡 채널 추가하기 기능을 추가해달라는 요청을 받았는데 찾아보니 pub.dev에도 없고..

네이티브 소스를 좀 작성해야 가능한 것처럼 보여서 코틀린과 Swift로 작업을 진행했습니다.

카카오 개발 설정은 다른 블로그 글도 많기 때문에 생략했습니다.

설정하셔서 해시키도 등록해두시고 native app key를 얻으셔서 진행하시길 바랍니다.

 

그리고 제가 이해하기로는 카카오톡 친구를 강제적으로 추가해주는 API는 존재하지 않아보이고,

(가입시 카카오에서 제공하는 체크박스 제외)

카카오톡 친구를 보여주어 채널을 추가를 유도하는 정도의 기능만 구현할 수 있어보여서 그렇게 진행하였습니다.

 

(22. 9. 1) 1년이 지난 지금, 찾아보니 Kakao에서 Flutter를 지원해서 아래 링크를 따라하시면 간단히 할 수 있게 되었네요.

아마 kakao_flutter_sdk 버전 1.0.0이 공식 출시 되면서 22년 3월 경에 해당 링크와 기능을 지원하기 시작한거 같습니다.

낮은 확률로 다른 패키지와 충돌해서 kakao_flutter_sdk를 사용하실 수 없을 수도 있으니 

제가 작성한 소스도 남겨 놓도록 하겠습니다.

가능하시면 아래 링크를 통해 연결해보세요.

https://developers.kakao.com/docs/latest/ko/kakaotalk-channel/flutter

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

1. yaml을 작성한다.

flutter_kakao_login: 3.3.0
url_launcher: 6.0.3 # 카카오톡 설치 여부를 위해 추가

 

2. main.dart를 작성한다.

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_kakao_login/flutter_kakao_login.dart';
import 'package:url_launcher/url_launcher.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '카카오톡 채널 추가 Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: '카카오톡 채널 추가 Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  void goKakaoChannel() async {
    final installed = await canLaunch(Platform.isIOS ? "kakaokompassauth://authorize" : "kakaolink://");
    if (installed) {
      final FlutterKakaoLogin kakaoSignIn = new FlutterKakaoLogin();
      await kakaoSignIn.init("your_native_app_key");
      const MethodChannel _channel = const MethodChannel('myChannel');
      _channel.invokeMethod('addKakaoChannel', "your_channel_public_id");
    } else {
      //카카오톡이 설치되지 않았을때 처리
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            FloatingActionButton(
              onPressed: goKakaoChannel,
              tooltip: '카카오톡 채널 추가',
              child: Icon(Icons.add),
            )
          ],
        ),
      ),
    );
  }
}

 

3.  build.gradle(Project)에서 카카오 레파지토리 설정한다.

allprojects {
    repositories {
        google()
        jcenter()
        maven { url 'https://devrepo.kakao.com/nexus/content/groups/public/' }
    }
}

 

4. build.gradle(Module)에 디펜던시를 설정한다.

dependencies {
    implementation "com.kakao.sdk:v2-talk:2.4.2" // 친구, 메시지(카카오톡)
}

 

5. MainActivity.kt를 작성한다.

package com.risha.blog

import androidx.annotation.NonNull

import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugins.GeneratedPluginRegistrant
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

import com.kakao.sdk.talk.*
import com.kakao.sdk.common.util.*

class MainActivity: FlutterActivity() {

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        GeneratedPluginRegistrant.registerWith(flutterEngine)
        val channel = MethodChannel(flutterEngine.getDartExecutor(), "myChannel")
        channel.setMethodCallHandler(handler)
    }


    private val handler: MethodChannel.MethodCallHandler = MethodChannel.MethodCallHandler({ methodCall, result ->
        if (methodCall.method.equals("addKakaoChannel")) {
            val channelPublicId = methodCall.arguments as String
            val url = TalkApiClient.instance.channelChatUrl(channelPublicId)

            // CustomTabs 로 열기
            KakaoCustomTabsClient.openWithDefault(context, url)
        } else {
            result.notImplemented()
        }
    })
}

 

6. Podfile을 작성한다. 

# Uncomment this line to define a global platform for your project
# platform :ios, '11.0'

pod 'KakaoSDKTalk'  # 친구, 메시지(카카오톡)

작성 뒤, pod install합니다.

 

7. Info.plist를 작성한다.

    <key>LSApplicationQueriesSchemes</key>
    <array>
        <!-- common -->
        <string>kakao${your_native_app_key}</string>

        <!-- KakaoTalk login -->
        <string>kakaokompassauth</string>
        <string>storykompassauth</string>
    </array>

 

8. AppDelegate.swift를 작성한다.

import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    var navigationController: UINavigationController!
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    linkNativeCode(controller: controller)
    GeneratedPluginRegistrant.register(with: self)
    
    self.navigationController = UINavigationController(rootViewController: controller)
    self.window.rootViewController = self.navigationController
    self.navigationController.setNavigationBarHidden(true, animated: false)
    self.window.makeKeyAndVisible()
    
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

    private func addKakaoChannel(call: FlutterMethodCall, result: @escaping FlutterResult) {
        let vc = UIStoryboard.init(name: "Main", bundle: .main)
                                .instantiateViewController(withIdentifier: "ViewController") as! ViewController
                        if let arguments = call.arguments as? String {
                            vc.arguments = arguments
                        }
        
                        vc.result = result
                        self.navigationController.pushViewController(vc, animated: true)
    }
}

extension AppDelegate {
    
    func linkNativeCode(controller: FlutterViewController) {
        setupMethodChannel(controller: controller)
    }
    
    private func setupMethodChannel(controller: FlutterViewController) {
        let commonChannel = FlutterMethodChannel(name: "myChannel",
                                                  binaryMessenger: controller.binaryMessenger)
        
        commonChannel.setMethodCallHandler({
          (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
          // Note: this method is invoked on the UI thread.
            
            //let viewController = ViewController()
            if call.method == "addKakaoChannel" {
                self.addKakaoChannel(call: call, result: result)
            }
        })
    }
}

 

9. ViewController.swift를 작성한다.

import UIKit
import SafariServices
import KakaoSDKTalk

class ViewController: UIViewController, SFSafariViewControllerDelegate {
    var result: FlutterResult!
    var arguments: String!
    var safariViewController : SFSafariViewController? // to keep instance
    
    override func viewDidLoad() {
        super.viewDidLoad()

        self.safariViewController = SFSafariViewController(url: TalkApi.shared.makeUrlForChannelChat(channelPublicId: arguments)!)

        guard (self.safariViewController != nil) else { return }

        self.safariViewController?.modalTransitionStyle = .crossDissolve
        self.safariViewController?.modalPresentationStyle = .overCurrentContext
        self.safariViewController?.delegate = self
        self.present(self.safariViewController!, animated: true) {
            print("Kakao Talk Channel chat 연결 페이지 실행 성공")
        }
    }
    
    func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
        //To access the  Specific tabBar ViewController
        self.navigationController?.popViewController(animated: true)
    }
}

 

10. Storyboard ID를 작성한다.

AppDelegate.swift의 instantiateViewController(withIdentifier: "ViewController")는

Main.storyboard에 ViewController를 추가하신 뒤, Storyboard ID를 withIdentifier 값으로 기입하시면 됩니다.

 

 

 

 

 

참고 출처 :

https://blog.usejournal.com/integrating-native-third-party-sdk-in-flutter-8aab03afa9da

https://developers.kakao.com/docs/latest/ko/kakaotalk-channel/ios

 

반응형
:
Posted by 리샤씨
2020. 10. 8. 13:20

Swift에서 WKWebView Scroll 문제 해결 개발/Swift2020. 10. 8. 13:20

반응형

최근 회사에서 운영 중인 iOS 어플의 웹뷰를 앱 스토어의 권고 사항에 따라 UIWebView에서 WKWebView로 변경하였다.

WKWebView로 바꾸어서 그런지 일부 최신 폰에서 (보고 받은 것은 iPhone X, iPhone XR)

fixed 처리된 footer가 있는 줄 모르는 듯이 Scroll시 맨 아래까지 가지 않고 일부 내용을 가리게 되어 사용에 불편이 생겼다.

처음엔 그저 WKWebview의 문제인줄 알고 scroll bounces를 false로 적용하려고 하거나 status bar 문제 인가싶어 그 부분도 알아봤지만

더 찾아보니 iPhone 11에서 부터 Safe Area Zone이란 것이 생겨서 문제가 생긴 듯하였다.

그래서 해결한 코드를 공유하도록 하겠다.

 

import UIKit
import WebKit

class ViewController: UIViewController  {
    @IBOutlet var webView: WKWebView!
    
    private func setupWebView() {
        webView = WKWebView(frame: self.view.bounds, configuration: WKWebViewConfiguration())
        view.addSubview(webView)
        
        if #available(iOS 11.0, *) {
            webView.scrollView.contentInsetAdjustmentBehavior = .never
        } else {
            // Fallback on earlier versions
            webView.translatesAutoresizingMaskIntoConstraints = false
            view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[v0]|", options: NSLayoutConstraint.FormatOptions(), metrics: nil, views: ["v0":webView]))
            view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-20-[v0]|", options: NSLayoutConstraint.FormatOptions(), metrics: nil, views: ["v0":webView]))
        }
    }
    
    override func viewSafeAreaInsetsDidChange() {
        if #available(iOS 11.0, *) {
            webView.translatesAutoresizingMaskIntoConstraints = false
            view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[v0]|", options: NSLayoutConstraint.FormatOptions(), metrics: nil, views: ["v0":webView]))
            view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-(\(self.view.safeAreaInsets.top))-[v0]-(\(self.view.safeAreaInsets.bottom))-|", options: NSLayoutConstraint.FormatOptions(), metrics: nil, views: ["v0":webView]))
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.setupWebView()
    }
}

위 코드처럼 iOS 11 버전 이상인지 아닌지에 따라 제약 조건을 분기하면 해결된다.

Safe Area Zone이 iOS 11 버전 이상부터 생김에 따라

safeAreaInsets 기능도 같이 iOS 11 버전 이상에서만 지원해주기 때문에 분기 코드는 필수이다.

만약, 하단은 가득 채우고 싶다면 25줄에서 -(\(self.view.safeAreaInsets.bottom))-를 삭제하면 된다.

 

 

참고 출처 : https://wit.nts-corp.com/2019/10/24/5731

stackoverflow.com/questions/45421548/ios-wkwebview-status-bar-padding

반응형
:
Posted by 리샤씨
반응형

최근, 전자정부 프레임워크에서 사용하고 있는 properties를 yaml로 바꿔야 하는 상황이 생겼다.

이미 서비스 중인 프로젝트였기 때문에 최대한 소스 수정은 피하고 적용하는 법을 찾아야만 했다.

또한, 로컬 서버와 테스트 서버, 실서버의 properties는

톰캣의 시스템 변수 설정(-Dspring.profiles.active)으로 분리되어 있었기 때문에

yaml로 변경해도 서버에 따른 분리가 필요했다.

 

단순히 properties를 yaml로 바꾸는 변경 자체는 굉장히 쉬었으나 소스는 properties처럼 사용하면서

yaml안에서도 spring.profiles.active를 변경 가능하도록 하느랴 신경을 많이 쓴 코드이다.

 

1. application.yml 작성한다.

spring: 
  profiles: 
    active: local

---
spring: 
  profiles: local
  
jdbc: 
  url: jdbc:mysql://${hostname}:${port}/${database}
  username: ${username}
  password: ${password}
  
---
spring: 
  profiles: dev
  
jdbc: 
  url: jdbc:mysql://${hostname}:${port}/${database}
  username: ${username}
  password: ${password}
  
---
spring: 
  profiles: prod
  
jdbc: 
  url: jdbc:mysql://${hostname}:${port}/${database}
  username: ${username}
  password: ${password}

 

2. pom.xml에 dependency를 추가한다.

<!-- yaml -->
<dependency>
  <groupId>org.yaml</groupId>
  <artifactId>snakeyaml</artifactId>
  <version>1.17</version>
</dependency>

 

3. ArrayDocumentMatcher.java를 추가한다.

import java.util.Collections;
import java.util.Properties;
import java.util.Set;

import org.springframework.beans.factory.config.YamlProcessor.DocumentMatcher;
import org.springframework.beans.factory.config.YamlProcessor.MatchStatus;
import org.springframework.util.StringUtils;

/**
 * Matches a document containing a given key and where the value of that key is an array
 * containing one of the given values, or where one of the values matches one of the given
 * values (interpreted as regexes).
 *
 * @author Dave Syer
 * @deprecated as of 1.4.1 in favor of exact String-based matching
 */
@Deprecated
public class ArrayDocumentMatcher implements DocumentMatcher {
	private final String key;
	private final String[] patterns;

	public ArrayDocumentMatcher(final String key, final String... patterns) {
		this.key = key;
		this.patterns = patterns;
	}

	@Override
	public MatchStatus matches(Properties properties) {
		if (!properties.containsKey(this.key)) {
			return MatchStatus.ABSTAIN;
		}
		Set<String> values = StringUtils
				.commaDelimitedListToSet(properties.getProperty(this.key));
		if (values.isEmpty()) {
			values = Collections.singleton("");
		}
		for (String pattern : this.patterns) {
			for (String value : values) {
				if (value.matches(pattern)) {
					return MatchStatus.FOUND;
				}
			}
		}
		return MatchStatus.NOT_FOUND;
	}
}

 

4. SpringProfileDocumentMatcher.java를 추가한다.

import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Properties;

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.YamlProcessor.DocumentMatcher;
import org.springframework.beans.factory.config.YamlProcessor.MatchStatus;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.EnvironmentAware;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;

public class SpringProfileDocumentMatcher implements DocumentMatcher, EnvironmentAware, ApplicationContextAware {
	private ApplicationContext ctx;
	private static final String[] DEFAULT_PROFILES = new String[] {"local"};
	private String[] activeProfiles = new String[0];
	
	public SpringProfileDocumentMatcher() {}
	
	@Override
	public void setEnvironment(Environment environment) {
	    if (environment != null) {
	        addActiveProfiles(environment.getActiveProfiles());
	    }
	}
	
	public void addActiveProfiles(String... profiles) {
	    LinkedHashSet<String> set = new LinkedHashSet<String>(Arrays.asList(this.activeProfiles));
	    Collections.addAll(set, profiles);
	    this.activeProfiles = set.toArray(new String[set.size()]);
	}

	@Bean(name = "yamlProperties")
	public YamlPropertiesFactoryBean yamlPropertiesFactoryBean() throws IOException {
	    YamlPropertiesFactoryBean yamlPropertiesFactoryBean = new YamlPropertiesFactoryBean();
	    yamlPropertiesFactoryBean.setResources(ctx.getResources("classpath:application.yml"));
        
	    return yamlPropertiesFactoryBean;
	}
    
	@Override
	public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
	    this.ctx = applicationContext;
	}
	
	@Override
	public MatchStatus matches(Properties properties) {
	    String[] profiles = this.activeProfiles;
	    if (profiles.length == 0) {
	        try {
	        	String activeProfile = yamlPropertiesFactoryBean().getObject().getProperty("spring.profiles.active");
	        	if (activeProfile != null && activeProfile != "") {
	        		profiles = new String[] { activeProfile };
	        	}
	        } catch (IOException e) {
	        	// TODO Auto-generated catch block
	        	e.printStackTrace();
	        }
	        
	        if (profiles.length == 0) {
	        	profiles = DEFAULT_PROFILES;
	        }
	    }
	    return new ArrayDocumentMatcher("spring.profiles", profiles).matches(properties);
	}
}

 

5.  context.xml을 수정한다.

	<!-- Old Properties Files -->
	<!-- <bean id="propertyConfigurer" class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
		<property name="locations">
			<list>
				<value>classpath:properties/config-#{systemProperties['spring.profiles.active']}.properties</value>
			</list>
		</property>
	</bean> -->
	
	<context:annotation-config/>

	<bean id="yamlProperties" class="org.springframework.beans.factory.config.YamlPropertiesFactoryBean">
		<property name="resources" value="classpath:application.yml"/>
		<property name="documentMatchers">
			<bean class="YourPackageName.SpringProfileDocumentMatcher" />
		</property>
	</bean>
	
	<context:property-placeholder properties-ref="yamlProperties"/> 

 

위 코드를 적용하면 톰캣의 시스템 변수 설정(-Dspring.profiles.active)를 적용하는 것이 1순위,

그 변수가 설정되지 않았으면 yaml 파일 안의 spring.profiles.active 값이 2순위,

그것마저 없다면 SpringProfileDocumentMatcher.java의 DEFAULT_PROFILES 값으로 적용된다.

 

참고 및 출처 :

- https://stackoverflow.com/questions/33525951/spring-yaml-profile-configuration

- https://jar-download.com/artifacts/org.springframework.boot/spring-boot/1.4.6.RELEASE/source-code/org/springframework/boot/yaml/ArrayDocumentMatcher.java

- https://yangbongsoo.gitbook.io/study/spring/spring_develop_issue1

 

 

 

반응형
:
Posted by 리샤씨
반응형

 

https://risha-lee.tistory.com/34

 

ionic에서 android 상에 scroll 보이게 하기

오늘 ionic으로 만든 android app에서 scroll이 보이지 않아 보이게 해 달라는 요청에 의해 scroll이 보이도록 작업하였다. 특이하게도 ionic serve 명령어를 통해 웹으로 띄운 화면에서는 잘 보였는데 ionic

risha-lee.tistory.com

↑위 글과 연결된 글입니다.

 

 

지난 글에서 언급했듯이 요청받은 내용은 그냥 스크롤이 보이는 것이 아닌,

스크롤을 이동할 땐 보이지만, 스크롤을 이동하지 않을 땐 숨겨져 있는 기능이었다.

 

그래서 좀 생각해보았는데 어차피 기본은 안 보이는 것이고,

css를 추가해야만 보이니 class를 이용하여 토글 하면 좋겠다고 생각하였다.

 

그래서 아래의 코드 형태로 결과를 내게 되었다.

 

1. scss를 작성한다.

.scroll_view .scroll-content{
  overflow-y: overlay;
}
.scroll_view .scroll-content::-webkit-scrollbar {
  -webkit-appearance: none;
}
.scroll_view .scroll-content::-webkit-scrollbar:vertical {
  width: 3px;
}
.scroll_view .scroll-content::-webkit-scrollbar:horizontal {
  height: 3px;
}
.scroll_view .scroll-content::-webkit-scrollbar-thumb {
  border-radius: 8px;
  background-color: rgba(0, 0, 0, .5);
}

 

2. html을 작성한다.

<ion-header>
  <ion-navbar>
    <ion-title>
      Ionic Blank
    </ion-title>
  </ion-navbar>
</ion-header>

<ion-content [ngClass]="scrolling ? 'scroll_view' : ''" 
			(ionScrollStart)="scrollStart()" 
			(ionScrollEnd)="scrollEnd()">
            
            <!-- 본문 내용 작성 -->
            
</ion-content> 

 

3. ts를 작성한다.

import { Component, NgZone } from '@angular/core';
import { Platform  } from 'ionic-angular';

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {
  scrolling = false;

  constructor(public platform: Platform,
              public _zone: NgZone) {
  }

  scrollStart() {
    if (this.platform.is('android')) { 
      console.log("scrollStart");
      this._zone.run(() => {
        this.scrolling = true;
      });
    }
  }

  scrollEnd() {
    if (this.platform.is('android')) { 
      console.log("scrollEnd");
      this._zone.run(() => {
        this.scrolling = false;
      });
    }
  }
}

 

위의 코드를 적용하여 android에서 실행해보면 스크롤을 이동할 때만 스크롤이 보여지는 것을 볼 수 있다.

 

여기서 NgZone은 좀 생소할 수도 있는데

일전에 연동되는 변수를 이용하여 화면을 열심히 바꿔도 적용이 되지 않아 찾은 기능이다.

https://angular.io/guide/zone에서 다시 찾아보니

NgZone을 사용하면 HTML에서 업데이트하기 위해 구성 요소의 변경 사항을 자동으로 감지할 수 있다고 한다.

나는 보통 NgZone제대로 코드를 작성하였는데도 뭔가 화면 변화가 이루어지지 않을 때, 사용해보는 편이다.

 

반응형
:
Posted by 리샤씨
반응형

오늘 ionic으로 만든 android app에서 scroll이 보이지 않아 보이게 해 달라는 요청에 의해

scroll이 보이도록 작업하였다.

 

특이하게도 ionic serve 명령어를 통해 웹으로 띄운 화면에서는 잘 보였는데

ionic cordova bulid 명령어를 통해 android에 올리면 보이지 않았다.

 

찾아보니 이 문제를 해결하고 싶은 경우가 좀 있었다.

ionic의 오류인 듯싶었다.

 

내가 적용한 해결법은 그냥 간단히 아래의 코드를 scss에 추가하면 된다.

(하지만 ios에서는 아쉽게도 작동하지 않았다. 추후 ios에서도 방법을 알게 된다면 글을 따로 올리도록 하겠다)

::-webkit-scrollbar {
  -webkit-appearance: none;
}
::-webkit-scrollbar:vertical {
  width: 3px;
}
::-webkit-scrollbar:horizontal {
  height: 3px;
}
::-webkit-scrollbar-thumb {
  border-radius: 8px;
  background-color: rgba(0, 0, 0, .5);
}

 

 

하지만 요청받은 내용은 그냥 스크롤이 보이는 것이 아닌,

스크롤을 이동할 땐 보이지만, 스크롤을 이동하지 않을 땐 숨겨져 있는 기능이었으므로

다음 글에서 해당 기능 추가하는 법을 설명하도록 하겠다.

 

 

출처 : https://stackoverflow.com/questions/43143299/ionic-scrollbar-is-not-visible-in-android-phone

반응형
:
Posted by 리샤씨


반응형
반응형